Skip to main content

oxihuman_morph/
jaw_control.rs

1//! Jaw movement and phoneme-driven jaw opening for speech animation.
2
3use std::collections::HashMap;
4
5/// Configuration for jaw movement ranges and dynamics.
6#[allow(dead_code)]
7#[derive(Clone, Debug)]
8pub struct JawConfig {
9    /// Maximum jaw opening angle (normalized 0..1).
10    pub max_open: f32,
11    /// Minimum jaw opening angle (normalized 0..1).
12    pub min_open: f32,
13    /// Maximum lateral offset (normalized -1..1).
14    pub max_lateral: f32,
15    /// Smoothing factor for jaw transitions (higher = snappier).
16    pub smoothing: f32,
17    /// Maximum angular velocity (units per second).
18    pub max_velocity: f32,
19}
20
21/// Runtime state of the jaw.
22#[allow(dead_code)]
23#[derive(Clone, Debug)]
24pub struct JawState {
25    /// Current jaw opening (0 = closed, 1 = fully open).
26    pub current_open: f32,
27    /// Target jaw opening to transition toward.
28    pub target_open: f32,
29    /// Current lateral offset (-1 left, 0 center, 1 right).
30    pub lateral_offset: f32,
31    /// Current velocity of jaw opening (units/s).
32    pub velocity: f32,
33}
34
35/// Maps phoneme strings to jaw opening values.
36#[allow(dead_code)]
37#[derive(Clone, Debug)]
38pub struct PhonemeJawMap {
39    /// Mapping from phoneme label to jaw opening amount (0..1).
40    pub entries: HashMap<String, f32>,
41}
42
43// ---------------------------------------------------------------------------
44// Construction
45// ---------------------------------------------------------------------------
46
47/// Create a default jaw configuration with sensible defaults.
48#[allow(dead_code)]
49pub fn default_jaw_config() -> JawConfig {
50    JawConfig {
51        max_open: 1.0,
52        min_open: 0.0,
53        max_lateral: 0.5,
54        smoothing: 10.0,
55        max_velocity: 5.0,
56    }
57}
58
59/// Create a new jaw state at rest (closed).
60#[allow(dead_code)]
61pub fn new_jaw_state() -> JawState {
62    JawState {
63        current_open: 0.0,
64        target_open: 0.0,
65        lateral_offset: 0.0,
66        velocity: 0.0,
67    }
68}
69
70// ---------------------------------------------------------------------------
71// Phoneme map
72// ---------------------------------------------------------------------------
73
74/// Build a default phoneme-to-jaw-opening map.
75#[allow(dead_code)]
76pub fn build_default_phoneme_map() -> PhonemeJawMap {
77    let mut entries = HashMap::new();
78    // Vowels - wider openings
79    entries.insert("AA".to_string(), 0.9);
80    entries.insert("AE".to_string(), 0.8);
81    entries.insert("AH".to_string(), 0.7);
82    entries.insert("AO".to_string(), 0.85);
83    entries.insert("AW".to_string(), 0.8);
84    entries.insert("AY".to_string(), 0.75);
85    entries.insert("EH".to_string(), 0.5);
86    entries.insert("ER".to_string(), 0.4);
87    entries.insert("EY".to_string(), 0.45);
88    entries.insert("IH".to_string(), 0.3);
89    entries.insert("IY".to_string(), 0.25);
90    entries.insert("OW".to_string(), 0.6);
91    entries.insert("OY".to_string(), 0.65);
92    entries.insert("UH".to_string(), 0.35);
93    entries.insert("UW".to_string(), 0.3);
94    // Consonants - smaller openings
95    entries.insert("B".to_string(), 0.05);
96    entries.insert("CH".to_string(), 0.2);
97    entries.insert("D".to_string(), 0.15);
98    entries.insert("DH".to_string(), 0.15);
99    entries.insert("F".to_string(), 0.1);
100    entries.insert("G".to_string(), 0.2);
101    entries.insert("HH".to_string(), 0.3);
102    entries.insert("JH".to_string(), 0.25);
103    entries.insert("K".to_string(), 0.2);
104    entries.insert("L".to_string(), 0.2);
105    entries.insert("M".to_string(), 0.0);
106    entries.insert("N".to_string(), 0.1);
107    entries.insert("NG".to_string(), 0.15);
108    entries.insert("P".to_string(), 0.0);
109    entries.insert("R".to_string(), 0.2);
110    entries.insert("S".to_string(), 0.1);
111    entries.insert("SH".to_string(), 0.15);
112    entries.insert("T".to_string(), 0.1);
113    entries.insert("TH".to_string(), 0.15);
114    entries.insert("V".to_string(), 0.1);
115    entries.insert("W".to_string(), 0.15);
116    entries.insert("Y".to_string(), 0.15);
117    entries.insert("Z".to_string(), 0.1);
118    entries.insert("ZH".to_string(), 0.15);
119    // Silence
120    entries.insert("SIL".to_string(), 0.0);
121    PhonemeJawMap { entries }
122}
123
124// ---------------------------------------------------------------------------
125// Core operations
126// ---------------------------------------------------------------------------
127
128/// Set the target jaw opening, clamped to 0..1.
129#[allow(dead_code)]
130pub fn set_jaw_open(state: &mut JawState, config: &JawConfig, amount: f32) {
131    state.target_open = amount.clamp(config.min_open, config.max_open);
132}
133
134/// Look up the jaw opening amount for a given phoneme string.
135/// Returns 0.0 if the phoneme is not found.
136#[allow(dead_code)]
137pub fn jaw_open_for_phoneme(map: &PhonemeJawMap, phoneme: &str) -> f32 {
138    map.entries.get(phoneme).copied().unwrap_or(0.0)
139}
140
141/// Smoothly update the jaw state toward its target over a time step `dt`.
142#[allow(dead_code)]
143pub fn update_jaw(state: &mut JawState, config: &JawConfig, dt: f32) {
144    if dt <= 0.0 {
145        return;
146    }
147    let diff = state.target_open - state.current_open;
148    let raw_velocity = diff * config.smoothing;
149    let clamped_velocity = raw_velocity.clamp(-config.max_velocity, config.max_velocity);
150    state.velocity = clamped_velocity;
151    let delta = clamped_velocity * dt;
152    state.current_open = (state.current_open + delta).clamp(config.min_open, config.max_open);
153}
154
155/// Return the current jaw open amount.
156#[allow(dead_code)]
157pub fn jaw_open_amount(state: &JawState) -> f32 {
158    state.current_open
159}
160
161/// Return the current lateral offset.
162#[allow(dead_code)]
163pub fn jaw_lateral_offset(state: &JawState) -> f32 {
164    state.lateral_offset
165}
166
167/// Set the lateral jaw offset, clamped to [-max_lateral, max_lateral].
168#[allow(dead_code)]
169pub fn set_jaw_lateral(state: &mut JawState, config: &JawConfig, offset: f32) {
170    state.lateral_offset = offset.clamp(-config.max_lateral, config.max_lateral);
171}
172
173/// Clamp jaw state values to the valid range specified by config.
174#[allow(dead_code)]
175pub fn clamp_jaw_range(state: &mut JawState, config: &JawConfig) {
176    state.current_open = state.current_open.clamp(config.min_open, config.max_open);
177    state.target_open = state.target_open.clamp(config.min_open, config.max_open);
178    state.lateral_offset = state
179        .lateral_offset
180        .clamp(-config.max_lateral, config.max_lateral);
181    state.velocity = state
182        .velocity
183        .clamp(-config.max_velocity, config.max_velocity);
184}
185
186/// Return the current jaw velocity.
187#[allow(dead_code)]
188pub fn jaw_velocity(state: &JawState) -> f32 {
189    state.velocity
190}
191
192/// Reset the jaw to its closed rest position.
193#[allow(dead_code)]
194pub fn reset_jaw(state: &mut JawState) {
195    state.current_open = 0.0;
196    state.target_open = 0.0;
197    state.lateral_offset = 0.0;
198    state.velocity = 0.0;
199}
200
201/// Blend two jaw states by a factor `t` (0 = all `a`, 1 = all `b`).
202#[allow(dead_code)]
203pub fn blend_jaw_states(a: &JawState, b: &JawState, t: f32) -> JawState {
204    let t = t.clamp(0.0, 1.0);
205    let inv = 1.0 - t;
206    JawState {
207        current_open: a.current_open * inv + b.current_open * t,
208        target_open: a.target_open * inv + b.target_open * t,
209        lateral_offset: a.lateral_offset * inv + b.lateral_offset * t,
210        velocity: a.velocity * inv + b.velocity * t,
211    }
212}
213
214/// Convert jaw state to morph target weights.
215///
216/// Returns a `HashMap` with keys like `"jaw_open"`, `"jaw_lateral"` mapped to
217/// their current weight values.
218#[allow(dead_code)]
219pub fn jaw_to_morph_weights(state: &JawState) -> HashMap<String, f32> {
220    let mut weights = HashMap::new();
221    weights.insert("jaw_open".to_string(), state.current_open);
222    weights.insert("jaw_lateral".to_string(), state.lateral_offset);
223    // Derived weights for speech
224    if state.current_open > 0.5 {
225        weights.insert("mouth_wide".to_string(), (state.current_open - 0.5) * 2.0);
226    } else {
227        weights.insert("mouth_wide".to_string(), 0.0);
228    }
229    if state.current_open < 0.2 {
230        weights.insert("lips_together".to_string(), 1.0 - state.current_open * 5.0);
231    } else {
232        weights.insert("lips_together".to_string(), 0.0);
233    }
234    weights
235}
236
237// ---------------------------------------------------------------------------
238// Tests
239// ---------------------------------------------------------------------------
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_default_config() {
247        let cfg = default_jaw_config();
248        assert!(cfg.max_open > 0.0);
249        assert!(cfg.smoothing > 0.0);
250        assert!(cfg.max_velocity > 0.0);
251    }
252
253    #[test]
254    fn test_new_jaw_state() {
255        let s = new_jaw_state();
256        assert_eq!(s.current_open, 0.0);
257        assert_eq!(s.target_open, 0.0);
258        assert_eq!(s.lateral_offset, 0.0);
259        assert_eq!(s.velocity, 0.0);
260    }
261
262    #[test]
263    fn test_set_jaw_open_clamps() {
264        let cfg = default_jaw_config();
265        let mut s = new_jaw_state();
266        set_jaw_open(&mut s, &cfg, 1.5);
267        assert!((s.target_open - 1.0).abs() < 1e-6);
268        set_jaw_open(&mut s, &cfg, -0.5);
269        assert!((s.target_open - 0.0).abs() < 1e-6);
270    }
271
272    #[test]
273    fn test_jaw_open_for_phoneme_found() {
274        let map = build_default_phoneme_map();
275        let val = jaw_open_for_phoneme(&map, "AA");
276        assert!(val > 0.8);
277    }
278
279    #[test]
280    fn test_jaw_open_for_phoneme_missing() {
281        let map = build_default_phoneme_map();
282        let val = jaw_open_for_phoneme(&map, "ZZZZZ");
283        assert_eq!(val, 0.0);
284    }
285
286    #[test]
287    fn test_update_jaw_toward_target() {
288        let cfg = default_jaw_config();
289        let mut s = new_jaw_state();
290        s.target_open = 1.0;
291        update_jaw(&mut s, &cfg, 0.1);
292        assert!(s.current_open > 0.0);
293        assert!(s.current_open < 1.0);
294    }
295
296    #[test]
297    fn test_update_jaw_zero_dt() {
298        let cfg = default_jaw_config();
299        let mut s = new_jaw_state();
300        s.target_open = 1.0;
301        update_jaw(&mut s, &cfg, 0.0);
302        assert_eq!(s.current_open, 0.0);
303    }
304
305    #[test]
306    fn test_jaw_open_amount() {
307        let mut s = new_jaw_state();
308        s.current_open = 0.42;
309        assert!((jaw_open_amount(&s) - 0.42).abs() < 1e-6);
310    }
311
312    #[test]
313    fn test_set_jaw_lateral() {
314        let cfg = default_jaw_config();
315        let mut s = new_jaw_state();
316        set_jaw_lateral(&mut s, &cfg, 0.3);
317        assert!((s.lateral_offset - 0.3).abs() < 1e-6);
318    }
319
320    #[test]
321    fn test_set_jaw_lateral_clamps() {
322        let cfg = default_jaw_config();
323        let mut s = new_jaw_state();
324        set_jaw_lateral(&mut s, &cfg, 10.0);
325        assert!((s.lateral_offset - cfg.max_lateral).abs() < 1e-6);
326    }
327
328    #[test]
329    fn test_clamp_jaw_range() {
330        let cfg = default_jaw_config();
331        let mut s = JawState {
332            current_open: 2.0,
333            target_open: -1.0,
334            lateral_offset: 5.0,
335            velocity: 100.0,
336        };
337        clamp_jaw_range(&mut s, &cfg);
338        assert!(s.current_open <= cfg.max_open);
339        assert!(s.target_open >= cfg.min_open);
340        assert!(s.lateral_offset <= cfg.max_lateral);
341        assert!(s.velocity <= cfg.max_velocity);
342    }
343
344    #[test]
345    fn test_jaw_velocity() {
346        let cfg = default_jaw_config();
347        let mut s = new_jaw_state();
348        s.target_open = 1.0;
349        update_jaw(&mut s, &cfg, 0.01);
350        assert!(jaw_velocity(&s).abs() > 0.0);
351    }
352
353    #[test]
354    fn test_reset_jaw() {
355        let mut s = JawState {
356            current_open: 0.5,
357            target_open: 0.8,
358            lateral_offset: 0.2,
359            velocity: 1.0,
360        };
361        reset_jaw(&mut s);
362        assert_eq!(s.current_open, 0.0);
363        assert_eq!(s.target_open, 0.0);
364        assert_eq!(s.lateral_offset, 0.0);
365        assert_eq!(s.velocity, 0.0);
366    }
367
368    #[test]
369    fn test_blend_jaw_states_zero() {
370        let a = JawState {
371            current_open: 0.0,
372            target_open: 0.0,
373            lateral_offset: 0.0,
374            velocity: 0.0,
375        };
376        let b = JawState {
377            current_open: 1.0,
378            target_open: 1.0,
379            lateral_offset: 0.5,
380            velocity: 2.0,
381        };
382        let r = blend_jaw_states(&a, &b, 0.0);
383        assert!((r.current_open - 0.0).abs() < 1e-6);
384    }
385
386    #[test]
387    fn test_blend_jaw_states_one() {
388        let a = JawState {
389            current_open: 0.0,
390            target_open: 0.0,
391            lateral_offset: 0.0,
392            velocity: 0.0,
393        };
394        let b = JawState {
395            current_open: 1.0,
396            target_open: 1.0,
397            lateral_offset: 0.5,
398            velocity: 2.0,
399        };
400        let r = blend_jaw_states(&a, &b, 1.0);
401        assert!((r.current_open - 1.0).abs() < 1e-6);
402    }
403
404    #[test]
405    fn test_blend_jaw_states_half() {
406        let a = JawState {
407            current_open: 0.0,
408            target_open: 0.0,
409            lateral_offset: 0.0,
410            velocity: 0.0,
411        };
412        let b = JawState {
413            current_open: 1.0,
414            target_open: 1.0,
415            lateral_offset: 0.5,
416            velocity: 2.0,
417        };
418        let r = blend_jaw_states(&a, &b, 0.5);
419        assert!((r.current_open - 0.5).abs() < 1e-6);
420    }
421
422    #[test]
423    fn test_jaw_to_morph_weights_closed() {
424        let s = new_jaw_state();
425        let w = jaw_to_morph_weights(&s);
426        assert_eq!(*w.get("jaw_open").expect("should succeed"), 0.0);
427        assert!(*w.get("lips_together").expect("should succeed") > 0.9);
428    }
429
430    #[test]
431    fn test_jaw_to_morph_weights_wide_open() {
432        let s = JawState {
433            current_open: 0.8,
434            target_open: 0.8,
435            lateral_offset: 0.0,
436            velocity: 0.0,
437        };
438        let w = jaw_to_morph_weights(&s);
439        assert!(*w.get("mouth_wide").expect("should succeed") > 0.0);
440        assert_eq!(*w.get("lips_together").expect("should succeed"), 0.0);
441    }
442
443    #[test]
444    fn test_phoneme_map_has_many_entries() {
445        let map = build_default_phoneme_map();
446        assert!(map.entries.len() >= 30);
447    }
448
449    #[test]
450    fn test_update_jaw_converges() {
451        let cfg = default_jaw_config();
452        let mut s = new_jaw_state();
453        s.target_open = 0.5;
454        for _ in 0..200 {
455            update_jaw(&mut s, &cfg, 0.016);
456        }
457        assert!((s.current_open - 0.5).abs() < 0.01);
458    }
459}