Skip to main content

secs_types/
role.rs

1//! Speaker-role vocabulary shared by project files and later service layers.
2
3use 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    // Rust's Alphabetic derived property includes some combining marks that
147    // Python's str.isalnum rejects. U+0903 is the observed project-title case.
148    ch != '\u{0903}' && ch.is_alphanumeric()
149}
150
151fn python_isdigit(ch: char) -> bool {
152    // Python 3.13's Unicode 15.1 Digit property: Numeric_Type=Digit or Decimal.
153    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}