1use serde::{Deserialize, Serialize};
4
5pub const ROLES: [&str; 27] = [
6 "L", "R", "C", "SW1", "SW2", "SW3", "SW4", "SW5", "SW6", "SW7", "SW8", "Ls", "Rs", "Lb", "Rb",
7 "Lw", "Rw", "Tfl", "Tfr", "Tml", "Tmr", "Tbl", "Tbr", "Hfl", "Hfr", "Hbl", "Hbr",
8];
9pub const SUB_ROLES: [&str; 8] = ["SW1", "SW2", "SW3", "SW4", "SW5", "SW6", "SW7", "SW8"];
10pub const TOP_GROUP: &str = "탑";
11pub const HEIGHT_GROUP: &str = "하이트";
12pub const LEGACY_TOP_GROUP: &str = "하이트";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum RoleKind {
17 Main,
18 Sub,
19 Top,
20 Height,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct RoleInfo {
25 pub label: &'static str,
26 pub kind: RoleKind,
27 pub group: &'static str,
28}
29
30const CUSTOM: RoleInfo = RoleInfo {
31 label: "Custom",
32 kind: RoleKind::Main,
33 group: "기타",
34};
35
36pub fn role_info(role: &str) -> RoleInfo {
37 let (label, kind, group) = match role {
38 "L" => ("Left", RoleKind::Main, "프론트"),
39 "R" => ("Right", RoleKind::Main, "프론트"),
40 "C" => ("Center", RoleKind::Main, "센터"),
41 "SW1" => ("Subwoofer 1", RoleKind::Sub, "서브"),
42 "SW2" => ("Subwoofer 2", RoleKind::Sub, "서브"),
43 "SW3" => ("Subwoofer 3", RoleKind::Sub, "서브"),
44 "SW4" => ("Subwoofer 4", RoleKind::Sub, "서브"),
45 "SW5" => ("Subwoofer 5", RoleKind::Sub, "서브"),
46 "SW6" => ("Subwoofer 6", RoleKind::Sub, "서브"),
47 "SW7" => ("Subwoofer 7", RoleKind::Sub, "서브"),
48 "SW8" => ("Subwoofer 8", RoleKind::Sub, "서브"),
49 "Ls" => ("Left Surround", RoleKind::Main, "서라운드"),
50 "Rs" => ("Right Surround", RoleKind::Main, "서라운드"),
51 "Lb" => ("Left Rear Surround", RoleKind::Main, "백"),
52 "Rb" => ("Right Rear Surround", RoleKind::Main, "백"),
53 "Lw" => ("Left Wide", RoleKind::Main, "와이드"),
54 "Rw" => ("Right Wide", RoleKind::Main, "와이드"),
55 "Tfl" => ("Left Top Front", RoleKind::Top, TOP_GROUP),
56 "Tfr" => ("Right Top Front", RoleKind::Top, TOP_GROUP),
57 "Tml" => ("Left Top Middle", RoleKind::Top, TOP_GROUP),
58 "Tmr" => ("Right Top Middle", RoleKind::Top, TOP_GROUP),
59 "Tbl" => ("Left Top Rear", RoleKind::Top, TOP_GROUP),
60 "Tbr" => ("Right Top Rear", RoleKind::Top, TOP_GROUP),
61 "Hfl" => ("Left Front Height", RoleKind::Height, HEIGHT_GROUP),
62 "Hfr" => ("Right Front Height", RoleKind::Height, HEIGHT_GROUP),
63 "Hbl" => ("Left Rear Height", RoleKind::Height, HEIGHT_GROUP),
64 "Hbr" => ("Right Rear Height", RoleKind::Height, HEIGHT_GROUP),
65 _ => return CUSTOM,
66 };
67 RoleInfo { label, kind, group }
68}
69
70pub fn role_label(role: &str) -> &'static str {
71 role_info(role).label
72}
73
74pub fn role_kind(role: &str) -> RoleKind {
75 role_info(role).kind
76}
77
78pub fn role_group(role: &str) -> &'static str {
79 if role_kind(role) == RoleKind::Sub {
80 "sub"
81 } else {
82 role_info(role).group
83 }
84}
85
86pub fn role_code(role: &str) -> &str {
87 match role {
88 "Lb" => "Lrs",
89 "Rb" => "Rrs",
90 "Tfl" => "Ltf",
91 "Tfr" => "Rtf",
92 "Tml" => "Ltm",
93 "Tmr" => "Rtm",
94 "Tbl" => "Ltr",
95 "Tbr" => "Rtr",
96 "Hfl" => "Lfh",
97 "Hfr" => "Rfh",
98 "Hbl" => "Lrh",
99 "Hbr" => "Rrh",
100 _ => role,
101 }
102}
103
104pub fn role_order(role: &str) -> usize {
105 let base = role.split(':').next().unwrap_or(role);
106 ROLES
107 .iter()
108 .position(|known| *known == base)
109 .unwrap_or(ROLES.len())
110}
111
112pub fn role_position(role: &str) -> Option<(f64, f64)> {
113 Some(match role {
114 "L" => (0.27, 0.18),
115 "R" => (0.73, 0.18),
116 "C" => (0.5, 0.12),
117 "SW1" => (0.16, 0.4),
118 "SW2" => (0.84, 0.4),
119 "SW3" => (0.16, 0.78),
120 "SW4" => (0.84, 0.78),
121 "SW5" => (0.5, 0.04),
122 "SW6" => (0.5, 0.97),
123 "SW7" => (0.04, 0.5),
124 "SW8" => (0.96, 0.5),
125 "Ls" => (0.1, 0.62),
126 "Rs" => (0.9, 0.62),
127 "Lb" => (0.22, 0.93),
128 "Rb" => (0.78, 0.93),
129 "Lw" => (0.06, 0.34),
130 "Rw" => (0.94, 0.34),
131 "Tfl" => (0.36, 0.3),
132 "Tfr" => (0.64, 0.3),
133 "Tml" => (0.36, 0.5),
134 "Tmr" => (0.64, 0.5),
135 "Tbl" => (0.36, 0.84),
136 "Tbr" => (0.64, 0.84),
137 "Hfl" => (0.14, 0.08),
138 "Hfr" => (0.86, 0.08),
139 "Hbl" => (0.07, 0.86),
140 "Hbr" => (0.93, 0.86),
141 _ => return None,
142 })
143}
144
145fn python_isalnum(ch: char) -> bool {
146 ch != '\u{0903}' && ch.is_alphanumeric()
149}
150
151fn python_isdigit(ch: char) -> bool {
152 matches!(
154 ch,
155 '\u{30}'..='\u{39}'
156 | '\u{b2}'..='\u{b3}'
157 | '\u{b9}'
158 | '\u{660}'..='\u{669}'
159 | '\u{6f0}'..='\u{6f9}'
160 | '\u{7c0}'..='\u{7c9}'
161 | '\u{966}'..='\u{96f}'
162 | '\u{9e6}'..='\u{9ef}'
163 | '\u{a66}'..='\u{a6f}'
164 | '\u{ae6}'..='\u{aef}'
165 | '\u{b66}'..='\u{b6f}'
166 | '\u{be6}'..='\u{bef}'
167 | '\u{c66}'..='\u{c6f}'
168 | '\u{ce6}'..='\u{cef}'
169 | '\u{d66}'..='\u{d6f}'
170 | '\u{de6}'..='\u{def}'
171 | '\u{e50}'..='\u{e59}'
172 | '\u{ed0}'..='\u{ed9}'
173 | '\u{f20}'..='\u{f29}'
174 | '\u{1040}'..='\u{1049}'
175 | '\u{1090}'..='\u{1099}'
176 | '\u{1369}'..='\u{1371}'
177 | '\u{17e0}'..='\u{17e9}'
178 | '\u{1810}'..='\u{1819}'
179 | '\u{1946}'..='\u{194f}'
180 | '\u{19d0}'..='\u{19da}'
181 | '\u{1a80}'..='\u{1a89}'
182 | '\u{1a90}'..='\u{1a99}'
183 | '\u{1b50}'..='\u{1b59}'
184 | '\u{1bb0}'..='\u{1bb9}'
185 | '\u{1c40}'..='\u{1c49}'
186 | '\u{1c50}'..='\u{1c59}'
187 | '\u{2070}'
188 | '\u{2074}'..='\u{2079}'
189 | '\u{2080}'..='\u{2089}'
190 | '\u{2460}'..='\u{2468}'
191 | '\u{2474}'..='\u{247c}'
192 | '\u{2488}'..='\u{2490}'
193 | '\u{24ea}'
194 | '\u{24f5}'..='\u{24fd}'
195 | '\u{24ff}'
196 | '\u{2776}'..='\u{277e}'
197 | '\u{2780}'..='\u{2788}'
198 | '\u{278a}'..='\u{2792}'
199 | '\u{a620}'..='\u{a629}'
200 | '\u{a8d0}'..='\u{a8d9}'
201 | '\u{a900}'..='\u{a909}'
202 | '\u{a9d0}'..='\u{a9d9}'
203 | '\u{a9f0}'..='\u{a9f9}'
204 | '\u{aa50}'..='\u{aa59}'
205 | '\u{abf0}'..='\u{abf9}'
206 | '\u{ff10}'..='\u{ff19}'
207 | '\u{104a0}'..='\u{104a9}'
208 | '\u{10a40}'..='\u{10a43}'
209 | '\u{10d30}'..='\u{10d39}'
210 | '\u{10e60}'..='\u{10e68}'
211 | '\u{11052}'..='\u{1105a}'
212 | '\u{11066}'..='\u{1106f}'
213 | '\u{110f0}'..='\u{110f9}'
214 | '\u{11136}'..='\u{1113f}'
215 | '\u{111d0}'..='\u{111d9}'
216 | '\u{112f0}'..='\u{112f9}'
217 | '\u{11450}'..='\u{11459}'
218 | '\u{114d0}'..='\u{114d9}'
219 | '\u{11650}'..='\u{11659}'
220 | '\u{116c0}'..='\u{116c9}'
221 | '\u{11730}'..='\u{11739}'
222 | '\u{118e0}'..='\u{118e9}'
223 | '\u{11950}'..='\u{11959}'
224 | '\u{11c50}'..='\u{11c59}'
225 | '\u{11d50}'..='\u{11d59}'
226 | '\u{11da0}'..='\u{11da9}'
227 | '\u{11f50}'..='\u{11f59}'
228 | '\u{16a60}'..='\u{16a69}'
229 | '\u{16ac0}'..='\u{16ac9}'
230 | '\u{16b50}'..='\u{16b59}'
231 | '\u{1d7ce}'..='\u{1d7ff}'
232 | '\u{1e140}'..='\u{1e149}'
233 | '\u{1e2f0}'..='\u{1e2f9}'
234 | '\u{1e4f0}'..='\u{1e4f9}'
235 | '\u{1e950}'..='\u{1e959}'
236 | '\u{1f100}'..='\u{1f10a}'
237 | '\u{1fbf0}'..='\u{1fbf9}'
238 )
239}
240
241fn python_whitespace(ch: char) -> bool {
242 ch.is_whitespace() || ('\u{001c}'..='\u{001f}').contains(&ch)
243}
244
245fn title_key(text: &str) -> String {
246 text.chars()
247 .flat_map(char::to_uppercase)
248 .filter(|ch| python_isalnum(*ch))
249 .collect()
250}
251
252fn python_words(text: &str) -> Vec<&str> {
253 text.split(python_whitespace)
254 .filter(|word| !word.is_empty())
255 .collect()
256}
257
258fn lookup_title(key: &str) -> Option<&'static str> {
259 for role in ROLES {
260 if title_key(role) == key || title_key(role_code(role)) == key {
261 return Some(role);
262 }
263 }
264 let direct = match key {
265 "FL" => "L",
266 "FR" => "R",
267 "FC" | "CEN" | "CENTER" => "C",
268 "SL" | "LSS" | "SIDEL" => "Ls",
269 "SR" | "RSS" | "SIDER" => "Rs",
270 "SBL" | "LSB" | "BL" => "Lb",
271 "SBR" | "RSB" | "BR" => "Rb",
272 "FWL" => "Lw",
273 "FWR" => "Rw",
274 "TFL" => "Tfl",
275 "TFR" => "Tfr",
276 "TML" => "Tml",
277 "TMR" => "Tmr",
278 "TRL" | "TBL" => "Tbl",
279 "TRR" | "TBR" => "Tbr",
280 "FHL" => "Hfl",
281 "FHR" => "Hfr",
282 "RHL" | "HRL" => "Hbl",
283 "RHR" | "HRR" => "Hbr",
284 "SUB" | "SW" | "LFE" | "SUBWOOFER" | "서브" | "서브우퍼" => "SW1",
285 _ => {
286 for prefix in ["LFE", "SUB", "SUBWOOFER", "서브", "서브우퍼"] {
287 if let Some(number) = key.strip_prefix(prefix) {
288 for (index, role) in SUB_ROLES.into_iter().enumerate() {
289 if number == (index + 1).to_string() {
290 return Some(role);
291 }
292 }
293 }
294 }
295 return None;
296 }
297 };
298 Some(direct)
299}
300
301pub fn role_from_title(title: &str, roles: Option<&[&str]>) -> Option<&'static str> {
302 let mut words = python_words(title);
303 if words.is_empty() {
304 return None;
305 }
306 let mut role = lookup_title(&title_key(&words.join(" ")));
307 while role.is_none()
308 && words.len() > 1
309 && words
310 .last()
311 .is_some_and(|word| !word.is_empty() && word.chars().all(python_isdigit))
312 {
313 words.pop();
314 role = lookup_title(&title_key(&words.join(" ")));
315 }
316 let role = role?;
317 if let Some(allowed) = roles
318 && !allowed
319 .iter()
320 .filter_map(|item| item.split(':').next())
321 .any(|base| base == role)
322 {
323 return None;
324 }
325 Some(role)
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn known_vocabulary_is_complete() {
334 assert_eq!(ROLES.len(), 27);
335 assert_eq!(role_group("SW8"), "sub");
336 assert_eq!(role_order("L:LF"), 0);
337 assert_eq!(role_from_title("LFE 2", None), Some("SW2"));
338 assert_eq!(role_from_title("L 1", None), Some("L"));
339 assert_eq!(role_from_title("L ١", None), Some("L"));
340 assert_eq!(role_from_title("L १", None), Some("L"));
341 assert_eq!(role_from_title("L ፩", None), Some("L"));
342 assert_eq!(role_from_title("L ²", None), Some("L"));
343 assert_eq!(role_from_title("L ⑴", None), Some("L"));
344 assert_eq!(role_from_title("L ½", None), None);
345 assert_eq!(role_from_title("L Ⅻ", None), None);
346 assert_eq!(role_from_title("L\u{0903}", None), Some("L"));
347 assert_eq!(role_from_title("L\u{001f}1", None), Some("L"));
348 assert_eq!(role_from_title("SUB01", None), None);
349 assert_eq!(role_from_title("서브우퍼02", None), None);
350 assert_eq!(role_from_title("DBA L", None), None);
351 }
352}