Skip to main content

oxihuman_morph/
ear_cup_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Ear-cup (protruding ear angle) control.
6
7/// Side.
8#[allow(dead_code)]
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum EarCupSide {
11    Left,
12    Right,
13    Both,
14}
15
16/// State.
17#[allow(dead_code)]
18#[derive(Clone, Debug)]
19pub struct EarCupState {
20    /// Cup/protrusion angle in normalised [0..1] range (0 = flat, 1 = max cup).
21    pub cup_left: f32,
22    pub cup_right: f32,
23    /// Top vs bottom cup bias (-1..1; 0 = uniform).
24    pub bias_left: f32,
25    pub bias_right: f32,
26}
27
28/// Config.
29#[allow(dead_code)]
30#[derive(Clone, Debug)]
31pub struct EarCupConfig {
32    pub max_cup: f32,
33}
34
35impl Default for EarCupConfig {
36    fn default() -> Self {
37        Self { max_cup: 1.0 }
38    }
39}
40
41impl Default for EarCupState {
42    fn default() -> Self {
43        Self {
44            cup_left: 0.0,
45            cup_right: 0.0,
46            bias_left: 0.0,
47            bias_right: 0.0,
48        }
49    }
50}
51
52#[allow(dead_code)]
53pub fn new_ear_cup_state() -> EarCupState {
54    EarCupState::default()
55}
56
57#[allow(dead_code)]
58pub fn default_ear_cup_config() -> EarCupConfig {
59    EarCupConfig::default()
60}
61
62#[allow(dead_code)]
63pub fn ec_set_cup(state: &mut EarCupState, cfg: &EarCupConfig, side: EarCupSide, v: f32) {
64    let v = v.clamp(0.0, cfg.max_cup);
65    match side {
66        EarCupSide::Left => state.cup_left = v,
67        EarCupSide::Right => state.cup_right = v,
68        EarCupSide::Both => {
69            state.cup_left = v;
70            state.cup_right = v;
71        }
72    }
73}
74
75#[allow(dead_code)]
76pub fn ec_set_bias(state: &mut EarCupState, side: EarCupSide, bias: f32) {
77    let b = bias.clamp(-1.0, 1.0);
78    match side {
79        EarCupSide::Left => state.bias_left = b,
80        EarCupSide::Right => state.bias_right = b,
81        EarCupSide::Both => {
82            state.bias_left = b;
83            state.bias_right = b;
84        }
85    }
86}
87
88#[allow(dead_code)]
89pub fn ec_reset(state: &mut EarCupState) {
90    *state = EarCupState::default();
91}
92
93#[allow(dead_code)]
94pub fn ec_is_neutral(state: &EarCupState) -> bool {
95    state.cup_left < 1e-4 && state.cup_right < 1e-4
96}
97
98#[allow(dead_code)]
99pub fn ec_blend(a: &EarCupState, b: &EarCupState, t: f32) -> EarCupState {
100    let t = t.clamp(0.0, 1.0);
101    EarCupState {
102        cup_left: a.cup_left + (b.cup_left - a.cup_left) * t,
103        cup_right: a.cup_right + (b.cup_right - a.cup_right) * t,
104        bias_left: a.bias_left + (b.bias_left - a.bias_left) * t,
105        bias_right: a.bias_right + (b.bias_right - a.bias_right) * t,
106    }
107}
108
109#[allow(dead_code)]
110pub fn ec_symmetry(state: &EarCupState) -> f32 {
111    1.0 - (state.cup_left - state.cup_right).abs().min(1.0)
112}
113
114#[allow(dead_code)]
115pub fn ec_average_cup(state: &EarCupState) -> f32 {
116    (state.cup_left + state.cup_right) * 0.5
117}
118
119#[allow(dead_code)]
120pub fn ec_to_weights(state: &EarCupState) -> [f32; 4] {
121    [
122        state.cup_left,
123        state.cup_right,
124        state.bias_left,
125        state.bias_right,
126    ]
127}
128
129#[allow(dead_code)]
130pub fn ec_to_json(state: &EarCupState) -> String {
131    format!(
132        "{{\"cup_left\":{:.4},\"cup_right\":{:.4},\"bias_left\":{:.4},\"bias_right\":{:.4}}}",
133        state.cup_left, state.cup_right, state.bias_left, state.bias_right
134    )
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn default_neutral() {
143        assert!(ec_is_neutral(&new_ear_cup_state()));
144    }
145
146    #[test]
147    fn set_cup_clamps_max() {
148        let mut s = new_ear_cup_state();
149        let cfg = default_ear_cup_config();
150        ec_set_cup(&mut s, &cfg, EarCupSide::Left, 99.0);
151        assert!(s.cup_left <= cfg.max_cup);
152    }
153
154    #[test]
155    fn set_cup_not_negative() {
156        let mut s = new_ear_cup_state();
157        let cfg = default_ear_cup_config();
158        ec_set_cup(&mut s, &cfg, EarCupSide::Right, -1.0);
159        assert!(s.cup_right >= 0.0);
160    }
161
162    #[test]
163    fn both_sides_set() {
164        let mut s = new_ear_cup_state();
165        let cfg = default_ear_cup_config();
166        ec_set_cup(&mut s, &cfg, EarCupSide::Both, 0.5);
167        assert!((s.cup_left - s.cup_right).abs() < 1e-5);
168    }
169
170    #[test]
171    fn reset_clears() {
172        let mut s = new_ear_cup_state();
173        let cfg = default_ear_cup_config();
174        ec_set_cup(&mut s, &cfg, EarCupSide::Both, 0.8);
175        ec_reset(&mut s);
176        assert!(ec_is_neutral(&s));
177    }
178
179    #[test]
180    fn blend_midpoint() {
181        let cfg = default_ear_cup_config();
182        let mut a = new_ear_cup_state();
183        let mut b = new_ear_cup_state();
184        ec_set_cup(&mut a, &cfg, EarCupSide::Left, 0.0);
185        ec_set_cup(&mut b, &cfg, EarCupSide::Left, 1.0);
186        let m = ec_blend(&a, &b, 0.5);
187        assert!((m.cup_left - 0.5).abs() < 1e-4);
188    }
189
190    #[test]
191    fn symmetry_one_equal() {
192        let s = new_ear_cup_state();
193        assert!((ec_symmetry(&s) - 1.0).abs() < 1e-5);
194    }
195
196    #[test]
197    fn average_cup_zero_default() {
198        assert!((ec_average_cup(&new_ear_cup_state())).abs() < 1e-5);
199    }
200
201    #[test]
202    fn weights_len() {
203        assert_eq!(ec_to_weights(&new_ear_cup_state()).len(), 4);
204    }
205
206    #[test]
207    fn json_has_cup_left() {
208        assert!(ec_to_json(&new_ear_cup_state()).contains("cup_left"));
209    }
210}