Download miranda-nodes/src/solver.rs from AIBRUH/miranda-engine: direct link, hf CLI and curl.
- Browser
- Download file 46.3 kB
-
https://huggingface.co/AIBRUH/miranda-engine/resolve/main/miranda-nodes/src/solver.rs
- Command line
-
hf download hf://AIBRUH/miranda-engine/miranda-nodes/src/solver.rs
-
curl -L -o solver.rs https://huggingface.co/AIBRUH/miranda-engine/resolve/main/miranda-nodes/src/solver.rs
46.3 kB
| //! WO-3 T7 β SIMD blend shape solver driven by raw audio energy features. | |
| //! | |
| //! This is the **Pipeline 2 (local)** speech-to-mouth path. It takes raw PCM | |
| //! and produces ARKit mouth weights directly, with no ASR, no phoneme | |
| //! alignment, and no network round trip. It is the counterpart to | |
| //! [`crate::viseme`], which is the **Pipeline 1 (cloud)** path driven by | |
| //! Amazon Polly viseme events. Both feed the same compositor. | |
| //! | |
| //! # What this is, and what it is not | |
| //! | |
| //! It is an **acoustic-to-articulator heuristic**: a small bandpass filter | |
| //! bank measuring energy in eight speech-relevant frequency bands, followed | |
| //! by a fixed linear basis mapping that spectral shape onto mouth channels. | |
| //! The mapping is grounded in formant phonetics β jaw opening tracks F1, | |
| //! lip rounding versus spreading tracks F2, sibilance lives above 3.5 kHz β | |
| //! but the coefficients are hand-authored, not fitted to data. | |
| //! | |
| //! It is **not** a trained audio-to-face regressor. A learned model | |
| //! (Audio2Face-class) will produce better coarticulation and better | |
| //! consonant detail than any hand-authored basis can, because much of what | |
| //! the mouth does is not recoverable from band energies at all: /p/, /b/ and | |
| //! /m/ are acoustically near-identical during closure and differ only in | |
| //! what follows, and tongue position behind closed lips has no acoustic | |
| //! signature whatsoever. `TONGUE_OUT` is therefore never driven here β a | |
| //! plausible-looking guess would be fabrication. | |
| //! | |
| //! What this path does buy, and why it exists: it is deterministic, it runs | |
| //! offline, it adds no latency beyond the analysis window, and its cost is | |
| //! bounded and measurable. That makes it the honest floor of the system β | |
| //! the mouth still moves correctly in amplitude and broad shape when the | |
| //! network is gone. | |
| //! | |
| //! # Why the SIMD axis is *bands*, not samples | |
| //! | |
| //! The filter bank is recursive: each output sample depends on the previous | |
| //! two, so the sample axis cannot be vectorised without restructuring the | |
| //! filters. The band axis is perfectly parallel β eight independent filters | |
| //! see the identical input sample. So the layout is eight bands across two | |
| //! [`f32x4`] vectors, and the inner loop advances all eight filters per | |
| //! sample with a handful of vector operations. | |
| //! | |
| //! Four-wide (`f32x4`, 128-bit SSE) is deliberate, not a default. The | |
| //! development machine (Celeron N4500) reports `sse sse2 sse3 sse4_1 sse4_2` | |
| //! and **no AVX, AVX2, or FMA**. `f32x8` on such a target is emulated as two | |
| //! 128-bit halves by `wide`, so widening the vectors would not widen the | |
| //! hardware β it would only make the band count harder to change. Eight | |
| //! bands over two `f32x4` also happens to be the right *acoustic* resolution | |
| //! for the formant structure being measured, so the two constraints agree. | |
| use miranda_core::{arkit, AUDIO_SAMPLE_RATE_HZ, BLENDSHAPE_COUNT}; | |
| use wide::f32x4; | |
| /// Number of analysis bands. Two [`f32x4`] vectors' worth. | |
| pub const BAND_COUNT: usize = 8; | |
| /// Number of SIMD vectors the band state occupies. | |
| const VEC_COUNT: usize = BAND_COUNT / 4; | |
| /// Band edges in Hz, chosen for speech structure at a 16 kHz sample rate | |
| /// (Nyquist 8 kHz). | |
| /// | |
| /// | band | range Hz | what it measures | | |
| /// |------|----------|------------------| | |
| /// | 0 | 100β300 | voicing / F0 β vocal fold activity | | |
| /// | 1 | 300β600 | F1 low β close vowels /i/ /u/ | | |
| /// | 2 | 600β1000 | F1 high β open vowels /a/ /Ι/ | | |
| /// | 3 | 1000β1600 | F2 low β back, rounded vowels /o/ /u/ | | |
| /// | 4 | 1600β2400 | F2 high β front, spread vowels /i/ /e/ | | |
| /// | 5 | 2400β3600 | F3 β /r/ colouring, vowel brightness | | |
| /// | 6 | 3600β5500 | sibilant onset /Κ/ /tΚ/ | | |
| /// | 7 | 5500β7800 | high fricative /s/ /z/ /f/ | | |
| /// | |
| /// The top edge stops at 7800 Hz rather than 8000: a resonator centred at | |
| /// Nyquist is unstable, and the bilinear transform's frequency warping is | |
| /// worst there. Leaving 200 Hz of headroom costs no meaningful sibilant | |
| /// energy. | |
| pub const BAND_EDGES_HZ: [(f32, f32); BAND_COUNT] = [ | |
| (100.0, 300.0), | |
| (300.0, 600.0), | |
| (600.0, 1000.0), | |
| (1000.0, 1600.0), | |
| (1600.0, 2400.0), | |
| (2400.0, 3600.0), | |
| (3600.0, 5500.0), | |
| (5500.0, 7800.0), | |
| ]; | |
| // Named band indices. Used by the basis table so the phonetic reasoning is | |
| // readable at the point of use rather than encoded in positional literals. | |
| const B_VOICING: usize = 0; | |
| const B_F1_LOW: usize = 1; | |
| const B_F1_HIGH: usize = 2; | |
| const B_F2_LOW: usize = 3; | |
| const B_F2_HIGH: usize = 4; | |
| const B_F3: usize = 5; | |
| const B_SIB_LOW: usize = 6; | |
| const B_SIB_HIGH: usize = 7; | |
| /// Level below which the input is treated as silence, in dBFS of RMS. | |
| pub const SILENCE_FLOOR_DBFS: f32 = -60.0; | |
| /// Level at which the mouth is considered fully driven, in dBFS of RMS. | |
| /// Above this, extra loudness does not open the mouth further β shouting | |
| /// does not detach the jaw. | |
| pub const FULL_DRIVE_DBFS: f32 = -14.0; | |
| /// Attack smoothing coefficient, per 16.67 ms frame. Mouth shapes are | |
| /// allowed to form quickly. | |
| pub const ATTACK_ALPHA: f32 = 0.55; | |
| /// Release smoothing coefficient, per 16.67 ms frame. Deliberately slower | |
| /// than attack: articulators are pulled into position by muscle and fall | |
| /// back passively, so closing lags opening. Symmetric smoothing is the | |
| /// single most recognisable tell of a cheap lip-sync β the mouth snaps shut | |
| /// between syllables and reads as a chattering puppet. | |
| pub const RELEASE_ALPHA: f32 = 0.25; | |
| // --------------------------------------------------------------------- | |
| // Filter bank | |
| // --------------------------------------------------------------------- | |
| /// Eight constant-skirt bandpass resonators evaluated in SIMD across bands. | |
| /// | |
| /// Each band is an RBJ biquad bandpass in transposed direct form II, which | |
| /// needs only two state values per band (rather than direct form I's four) | |
| /// and keeps the per-sample work to five vector multiply/adds. | |
| pub struct BandBank { | |
| /// Numerator gain. For a constant-skirt bandpass `b1 = 0` and | |
| /// `b2 = -b0`, so one coefficient covers the whole numerator. | |
| b0: [f32x4; VEC_COUNT], | |
| a1: [f32x4; VEC_COUNT], | |
| a2: [f32x4; VEC_COUNT], | |
| s1: [f32x4; VEC_COUNT], | |
| s2: [f32x4; VEC_COUNT], | |
| } | |
| impl BandBank { | |
| /// Builds the bank for [`AUDIO_SAMPLE_RATE_HZ`]. | |
| pub fn new() -> Self { | |
| Self::with_sample_rate(AUDIO_SAMPLE_RATE_HZ as f32) | |
| } | |
| /// Builds the bank for an arbitrary sample rate. | |
| /// | |
| /// Coefficients are computed once here, never in the hot loop. The | |
| /// trigonometry and division below are exactly what must not appear in | |
| /// the per-sample path. | |
| pub fn with_sample_rate(sample_rate_hz: f32) -> Self { | |
| let mut b0 = [0.0f32; BAND_COUNT]; | |
| let mut a1 = [0.0f32; BAND_COUNT]; | |
| let mut a2 = [0.0f32; BAND_COUNT]; | |
| let nyquist = sample_rate_hz * 0.5; | |
| for (i, &(lo, hi)) in BAND_EDGES_HZ.iter().enumerate() { | |
| // Geometric centre, not arithmetic: the bilinear-transform | |
| // bandpass is symmetric on a log frequency axis, so the | |
| // geometric centre is the frequency actually sitting mid-band. | |
| let centre = (lo * hi).sqrt(); | |
| // Keep the resonator away from Nyquist, where the biquad | |
| // degenerates. Bands are authored inside range for 16 kHz; this | |
| // guard is what keeps `with_sample_rate` honest if someone | |
| // instantiates the bank at 8 kHz. | |
| let centre = centre.min(nyquist * 0.95); | |
| let bandwidth = (hi - lo).min(nyquist * 0.9); | |
| let q = (centre / bandwidth).max(0.3); | |
| let w0 = std::f32::consts::TAU * centre / sample_rate_hz; | |
| let (sin_w0, cos_w0) = w0.sin_cos(); | |
| let alpha = sin_w0 / (2.0 * q); | |
| // RBJ constant-skirt bandpass, peak gain = Q. | |
| let norm = 1.0 + alpha; | |
| b0[i] = alpha / norm; | |
| a1[i] = (-2.0 * cos_w0) / norm; | |
| a2[i] = (1.0 - alpha) / norm; | |
| } | |
| Self { | |
| b0: pack(&b0), | |
| a1: pack(&a1), | |
| a2: pack(&a2), | |
| s1: [f32x4::ZERO; VEC_COUNT], | |
| s2: [f32x4::ZERO; VEC_COUNT], | |
| } | |
| } | |
| /// Clears filter state. Used between utterances so one turn's tail does | |
| /// not ring into the next. | |
| pub fn reset(&mut self) { | |
| self.s1 = [f32x4::ZERO; VEC_COUNT]; | |
| self.s2 = [f32x4::ZERO; VEC_COUNT]; | |
| } | |
| /// Runs `samples` through the bank and returns per-band RMS. | |
| /// | |
| /// Allocation-free: state lives in `self`, the accumulators are on the | |
| /// stack, and the return value is a fixed array. | |
| pub fn analyse(&mut self, samples: &[f32]) -> [f32; BAND_COUNT] { | |
| if samples.is_empty() { | |
| return [0.0; BAND_COUNT]; | |
| } | |
| let mut energy = [f32x4::ZERO; VEC_COUNT]; | |
| for &x in samples { | |
| // One scalar sample broadcast to every band β this is why the | |
| // band axis is the vectorisable one. | |
| let xv = f32x4::splat(x); | |
| for v in 0..VEC_COUNT { | |
| // Transposed direct form II: | |
| // y = b0*x + s1 | |
| // s1 = -a1*y + s2 (b1 == 0) | |
| // s2 = -b0*x - a2*y (b2 == -b0) | |
| let bx = self.b0[v] * xv; | |
| let y = bx + self.s1[v]; | |
| self.s1[v] = self.s2[v] - self.a1[v] * y; | |
| self.s2[v] = -bx - self.a2[v] * y; | |
| energy[v] += y * y; | |
| } | |
| } | |
| let inv_n = 1.0 / samples.len() as f32; | |
| let mut out = [0.0f32; BAND_COUNT]; | |
| for v in 0..VEC_COUNT { | |
| let e = (energy[v] * f32x4::splat(inv_n)).sqrt().to_array(); | |
| out[v * 4..v * 4 + 4].copy_from_slice(&e); | |
| } | |
| // A NaN or infinity here would propagate into every blend shape and | |
| // then into the ring buffer, where it becomes a permanently broken | |
| // face rather than one bad frame. Denormalised or absurd input is | |
| // cheaper to neutralise than to trace later. | |
| for e in out.iter_mut() { | |
| if !e.is_finite() { | |
| *e = 0.0; | |
| } | |
| } | |
| out | |
| } | |
| } | |
| impl Default for BandBank { | |
| fn default() -> Self { | |
| Self::new() | |
| } | |
| } | |
| fn pack(values: &[f32; BAND_COUNT]) -> [f32x4; VEC_COUNT] { | |
| let mut out = [f32x4::ZERO; VEC_COUNT]; | |
| for v in 0..VEC_COUNT { | |
| out[v] = f32x4::new([ | |
| values[v * 4], | |
| values[v * 4 + 1], | |
| values[v * 4 + 2], | |
| values[v * 4 + 3], | |
| ]); | |
| } | |
| out | |
| } | |
| /// Broadband RMS of a sample block, vectorised over samples. | |
| /// | |
| /// Unlike the filter bank this *is* parallel over samples, since squaring | |
| /// and summing carries no state. | |
| pub fn rms(samples: &[f32]) -> f32 { | |
| if samples.is_empty() { | |
| return 0.0; | |
| } | |
| let chunks = samples.chunks_exact(4); | |
| let tail = chunks.remainder(); | |
| let mut acc = f32x4::ZERO; | |
| for c in chunks { | |
| let v = f32x4::new([c[0], c[1], c[2], c[3]]); | |
| acc += v * v; | |
| } | |
| let mut sum = acc.reduce_add(); | |
| for &x in tail { | |
| sum += x * x; | |
| } | |
| let value = (sum / samples.len() as f32).sqrt(); | |
| if value.is_finite() { | |
| value | |
| } else { | |
| 0.0 | |
| } | |
| } | |
| // --------------------------------------------------------------------- | |
| // Acoustic features | |
| // --------------------------------------------------------------------- | |
| /// Intermediate features extracted from one analysis window. Exposed | |
| /// because the solver's behaviour is only auditable if the features feeding | |
| /// it are inspectable β a wrong weight and a wrong band energy look | |
| /// identical from the outside. | |
| pub struct AudioFeatures { | |
| /// Per-band RMS, unnormalised. | |
| pub band_rms: [f32; BAND_COUNT], | |
| /// Per-band share of total band energy, summing to 1.0 (or all zero for | |
| /// silence). This is the *shape* of the spectrum with loudness removed, | |
| /// which is what determines mouth shape. | |
| pub band_shape: [f32; BAND_COUNT], | |
| /// Broadband RMS. | |
| pub rms: f32, | |
| /// Loudness mapped to `[0, 1]` between [`SILENCE_FLOOR_DBFS`] and | |
| /// [`FULL_DRIVE_DBFS`]. This is what scales mouth motion. | |
| pub drive: f32, | |
| } | |
| impl AudioFeatures { | |
| /// Extracts features from one window. `bank` carries filter state | |
| /// across calls, so windows must be fed in order. | |
| pub fn extract(bank: &mut BandBank, samples: &[f32]) -> Self { | |
| let band_rms = bank.analyse(samples); | |
| let level = rms(samples); | |
| let total: f32 = band_rms.iter().sum(); | |
| let mut band_shape = [0.0f32; BAND_COUNT]; | |
| if total > 1e-9 { | |
| let inv = 1.0 / total; | |
| for i in 0..BAND_COUNT { | |
| band_shape[i] = band_rms[i] * inv; | |
| } | |
| } | |
| Self { | |
| band_rms, | |
| band_shape, | |
| rms: level, | |
| drive: drive_from_rms(level), | |
| } | |
| } | |
| } | |
| /// Maps RMS to a `[0, 1]` drive factor on a decibel scale. | |
| /// | |
| /// Decibels rather than linear amplitude because loudness perception and | |
| /// articulatory effort are both roughly logarithmic. A linear map spends | |
| /// almost all of its range on the loudest few decibels, so normal speech | |
| /// would sit near zero and the mouth would barely move until someone | |
| /// shouted. | |
| pub fn drive_from_rms(level: f32) -> f32 { | |
| if !(level > 0.0) || !level.is_finite() { | |
| return 0.0; | |
| } | |
| let dbfs = 20.0 * level.log10(); | |
| ((dbfs - SILENCE_FLOOR_DBFS) / (FULL_DRIVE_DBFS - SILENCE_FLOOR_DBFS)).clamp(0.0, 1.0) | |
| } | |
| // --------------------------------------------------------------------- | |
| // The basis | |
| // --------------------------------------------------------------------- | |
| /// One row of the solver basis: a mouth channel, its output gain, and its | |
| /// sensitivity to each band's share of the spectrum. | |
| /// | |
| /// Negative weights are *inhibitory* and are the load-bearing part. Lip | |
| /// rounding is not "energy at 1.2 kHz"; it is energy at 1.2 kHz **in the | |
| /// absence of** energy at 2 kHz. Without the negative term every vowel | |
| /// would round a little and every vowel would smile a little, and the mouth | |
| /// would converge on one mush shape for all speech. | |
| struct BasisRow { | |
| channel: usize, | |
| gain: f32, | |
| weights: [f32; BAND_COUNT], | |
| } | |
| /// Builds a weight array with named bands, so a row reads as phonetics | |
| /// rather than as eight positional numbers. | |
| const fn w(pairs: &[(usize, f32)]) -> [f32; BAND_COUNT] { | |
| let mut out = [0.0; BAND_COUNT]; | |
| let mut i = 0; | |
| while i < pairs.len() { | |
| out[pairs[i].0] = pairs[i].1; | |
| i += 1; | |
| } | |
| out | |
| } | |
| /// The solver basis. | |
| /// | |
| /// Every channel is referenced through [`arkit`] constants. No bare index | |
| /// literals: a wrong index here moves the wrong muscle and would pass every | |
| /// range check in this file. | |
| fn basis() -> [BasisRow; 11] { | |
| [ | |
| // Jaw opening tracks F1. High F1 (600β1000 Hz) means an open vocal | |
| // tract; energy concentrated at low F1 means a close vowel with the | |
| // jaw nearly shut. F2 contributes nothing to aperture. | |
| BasisRow { | |
| channel: arkit::JAW_OPEN, | |
| gain: 1.6, | |
| weights: w(&[(B_F1_HIGH, 1.0), (B_F1_LOW, 0.25), (B_SIB_HIGH, -0.5)]), | |
| }, | |
| // Bilabial closure /p/ /b/ /m/: voicing present, nothing above it. | |
| // The negative terms are the whole test β during closure the lips | |
| // block the high frequencies, so their absence *is* the signal. | |
| BasisRow { | |
| channel: arkit::MOUTH_CLOSE, | |
| gain: 1.3, | |
| weights: w(&[ | |
| (B_VOICING, 1.0), | |
| (B_F1_LOW, 0.2), | |
| (B_F2_HIGH, -0.7), | |
| (B_SIB_LOW, -0.7), | |
| (B_SIB_HIGH, -0.7), | |
| ]), | |
| }, | |
| // Rounding /o/ /u/ /w/: F2 pulled down. Inhibited by high F2, which | |
| // is the spread-lip signature and the acoustic opposite. | |
| BasisRow { | |
| channel: arkit::MOUTH_FUNNEL, | |
| gain: 1.7, | |
| weights: w(&[(B_F2_LOW, 1.0), (B_F2_HIGH, -0.8), (B_SIB_HIGH, -0.4)]), | |
| }, | |
| BasisRow { | |
| channel: arkit::MOUTH_PUCKER, | |
| gain: 1.5, | |
| weights: w(&[ | |
| (B_F2_LOW, 0.9), | |
| (B_F1_LOW, 0.3), | |
| (B_F2_HIGH, -0.7), | |
| (B_F1_HIGH, -0.4), | |
| ]), | |
| }, | |
| // Spreading /i/ /e/: F2 high, and high F3 brightness with it. | |
| BasisRow { | |
| channel: arkit::MOUTH_SMILE_LEFT, | |
| gain: 1.4, | |
| weights: w(&[(B_F2_HIGH, 1.0), (B_F3, 0.3), (B_F2_LOW, -0.6)]), | |
| }, | |
| BasisRow { | |
| channel: arkit::MOUTH_SMILE_RIGHT, | |
| gain: 1.4, | |
| weights: w(&[(B_F2_HIGH, 1.0), (B_F3, 0.3), (B_F2_LOW, -0.6)]), | |
| }, | |
| BasisRow { | |
| channel: arkit::MOUTH_STRETCH_LEFT, | |
| gain: 1.1, | |
| weights: w(&[(B_F2_HIGH, 0.8), (B_F3, 0.5), (B_F1_HIGH, -0.3)]), | |
| }, | |
| BasisRow { | |
| channel: arkit::MOUTH_STRETCH_RIGHT, | |
| gain: 1.1, | |
| weights: w(&[(B_F2_HIGH, 0.8), (B_F3, 0.5), (B_F1_HIGH, -0.3)]), | |
| }, | |
| // Labiodental /f/ /v/: high-frequency friction with little voicing. | |
| BasisRow { | |
| channel: arkit::MOUTH_SHRUG_LOWER, | |
| gain: 1.5, | |
| weights: w(&[(B_SIB_LOW, 0.8), (B_SIB_HIGH, 0.5), (B_VOICING, -0.8)]), | |
| }, | |
| BasisRow { | |
| channel: arkit::MOUTH_SHRUG_UPPER, | |
| gain: 1.2, | |
| weights: w(&[(B_SIB_HIGH, 0.7), (B_VOICING, -0.6)]), | |
| }, | |
| // Alveolar sibilants /s/ /z/ draw the lips slightly back over the | |
| // teeth; the roll is small and driven only by the top band. | |
| BasisRow { | |
| channel: arkit::MOUTH_ROLL_LOWER, | |
| gain: 0.7, | |
| weights: w(&[(B_SIB_HIGH, 0.8), (B_VOICING, -0.5)]), | |
| }, | |
| ] | |
| } | |
| // --------------------------------------------------------------------- | |
| // The solver | |
| // --------------------------------------------------------------------- | |
| /// Raw-audio-driven mouth blend shape solver. | |
| /// | |
| /// Feed it one window of PCM per rendered frame; it returns the full | |
| /// 52-channel weight array with only its own mouth channels non-zero. Like | |
| /// the oscillators, it never touches the autonomic channels β the | |
| /// compositor owns combining the two layers. | |
| pub struct AudioBlendshapeSolver { | |
| bank: BandBank, | |
| basis: [BasisRow; 11], | |
| /// Smoothed output, held across frames. | |
| current: [f32; BLENDSHAPE_COUNT], | |
| /// Untouched scratch for the pre-smoothing solve. Owned so the hot path | |
| /// allocates nothing. | |
| target: [f32; BLENDSHAPE_COUNT], | |
| attack_alpha: f32, | |
| release_alpha: f32, | |
| last_features: AudioFeatures, | |
| } | |
| impl AudioBlendshapeSolver { | |
| pub fn new() -> Self { | |
| Self::with_sample_rate(AUDIO_SAMPLE_RATE_HZ as f32) | |
| } | |
| pub fn with_sample_rate(sample_rate_hz: f32) -> Self { | |
| Self { | |
| bank: BandBank::with_sample_rate(sample_rate_hz), | |
| basis: basis(), | |
| current: [0.0; BLENDSHAPE_COUNT], | |
| target: [0.0; BLENDSHAPE_COUNT], | |
| // Both coefficients are clamped into (0, 1]. Zero would freeze | |
| // the mouth forever and values above 1 would overshoot and | |
| // oscillate β both are silent visual failures, so they are | |
| // rejected here rather than trusted. | |
| attack_alpha: ATTACK_ALPHA.clamp(f32::EPSILON, 1.0), | |
| release_alpha: RELEASE_ALPHA.clamp(f32::EPSILON, 1.0), | |
| last_features: AudioFeatures { | |
| band_rms: [0.0; BAND_COUNT], | |
| band_shape: [0.0; BAND_COUNT], | |
| rms: 0.0, | |
| drive: 0.0, | |
| }, | |
| } | |
| } | |
| /// Features from the most recent [`Self::solve`]. For telemetry and for | |
| /// the tests that need to distinguish a filter-bank error from a basis | |
| /// error. | |
| pub fn features(&self) -> &AudioFeatures { | |
| &self.last_features | |
| } | |
| /// Clears filter and smoothing state. | |
| pub fn reset(&mut self) { | |
| self.bank.reset(); | |
| self.current = [0.0; BLENDSHAPE_COUNT]; | |
| self.last_features = AudioFeatures { | |
| band_rms: [0.0; BAND_COUNT], | |
| band_shape: [0.0; BAND_COUNT], | |
| rms: 0.0, | |
| drive: 0.0, | |
| }; | |
| } | |
| /// Solves one frame from `samples` and returns the smoothed weights. | |
| /// | |
| /// Allocation-free, branch-light, and free of I/O and locking, so it is | |
| /// safe to call from the 60 FPS thread. | |
| pub fn solve(&mut self, samples: &[f32]) -> &[f32; BLENDSHAPE_COUNT] { | |
| let features = AudioFeatures::extract(&mut self.bank, samples); | |
| self.last_features = features; | |
| self.target = [0.0; BLENDSHAPE_COUNT]; | |
| // Silence is an explicit case, not an emergent one. With drive == 0 | |
| // every row would multiply out to zero anyway, but relying on that | |
| // means the *shape* is still computed from filter ringing during | |
| // silence, and any later change to the drive term would let that | |
| // ringing leak onto the face. | |
| if features.drive > 0.0 { | |
| for row in self.basis.iter() { | |
| let mut acc = 0.0f32; | |
| for b in 0..BAND_COUNT { | |
| acc += row.weights[b] * features.band_shape[b]; | |
| } | |
| // Rectify before scaling: a negative activation means "this | |
| // articulator is not indicated", which is zero, not a | |
| // negative weight. | |
| let activation = (acc * row.gain).max(0.0); | |
| self.target[row.channel] = (activation * features.drive).clamp(0.0, 1.0); | |
| } | |
| } | |
| // Asymmetric smoothing, applied per channel on its own direction of | |
| // travel. | |
| for i in 0..BLENDSHAPE_COUNT { | |
| let t = self.target[i]; | |
| let c = self.current[i]; | |
| let alpha = if t > c { | |
| self.attack_alpha | |
| } else { | |
| self.release_alpha | |
| }; | |
| self.current[i] = c + (t - c) * alpha; | |
| } | |
| &self.current | |
| } | |
| /// Current smoothed weights without advancing the solver. | |
| pub fn weights(&self) -> &[f32; BLENDSHAPE_COUNT] { | |
| &self.current | |
| } | |
| /// Adds the solved weights into `out`, additively, matching the | |
| /// oscillators' contract so the compositor can layer all sources | |
| /// uniformly and own the final clamp. | |
| pub fn add_into(&self, out: &mut [f32; BLENDSHAPE_COUNT]) { | |
| for i in 0..BLENDSHAPE_COUNT { | |
| out[i] += self.current[i]; | |
| } | |
| } | |
| } | |
| impl Default for AudioBlendshapeSolver { | |
| fn default() -> Self { | |
| Self::new() | |
| } | |
| } | |
| mod tests { | |
| use super::*; | |
| use std::time::Instant; | |
| const SR: f32 = AUDIO_SAMPLE_RATE_HZ as f32; | |
| /// Samples per rendered frame at 60 FPS and 16 kHz: 16.67 ms. | |
| const FRAME_SAMPLES: usize = 267; | |
| fn sine(freq: f32, amp: f32, n: usize, phase_offset: f32) -> Vec<f32> { | |
| (0..n) | |
| .map(|i| { | |
| (std::f32::consts::TAU * freq * i as f32 / SR + phase_offset).sin() * amp | |
| }) | |
| .collect() | |
| } | |
| /// Two summed sinusoids at F1 and F2. Not a real vocal tract β a real | |
| /// one has a harmonic source and a full formant envelope β but the | |
| /// solver reads band energy, and a formant pair puts energy in the same | |
| /// bands a real one would. | |
| fn vowel(f1: f32, f2: f32, amp: f32, n: usize) -> Vec<f32> { | |
| let a = sine(f1, amp * 0.6, n, 0.0); | |
| let b = sine(f2, amp * 0.4, n, 1.0); | |
| a.iter().zip(b.iter()).map(|(x, y)| x + y).collect() | |
| } | |
| /// Deterministic pseudo-noise band-limited by summing many sines across | |
| /// a range. Used for fricatives. | |
| fn noise_band(lo: f32, hi: f32, amp: f32, n: usize) -> Vec<f32> { | |
| let mut out = vec![0.0f32; n]; | |
| let lines = 24; | |
| for k in 0..lines { | |
| let f = lo + (hi - lo) * (k as f32 / (lines - 1) as f32); | |
| let phase = k as f32 * 1.7; | |
| for (i, o) in out.iter_mut().enumerate() { | |
| *o += (std::f32::consts::TAU * f * i as f32 / SR + phase).sin(); | |
| } | |
| } | |
| let scale = amp / (lines as f32).sqrt(); | |
| for o in out.iter_mut() { | |
| *o *= scale; | |
| } | |
| out | |
| } | |
| /// Runs enough frames for the smoother to settle, then returns the | |
| /// weights. A single frame only shows `attack_alpha` of the target, so | |
| /// asserting on one frame would test the smoother, not the basis. | |
| fn settle(solver: &mut AudioBlendshapeSolver, block: &[f32], frames: usize) -> [f32; BLENDSHAPE_COUNT] { | |
| let mut last = [0.0; BLENDSHAPE_COUNT]; | |
| for _ in 0..frames { | |
| last = *solver.solve(block); | |
| } | |
| last | |
| } | |
| // ----------------------------------------------------------------- | |
| // Filter bank correctness β the foundation everything else rests on | |
| // ----------------------------------------------------------------- | |
| /// **The load-bearing test of this module.** Each band must respond | |
| /// most strongly to its own centre frequency. | |
| /// | |
| /// If the biquad coefficients are wrong, or the geometric centre is | |
| /// miscomputed, or the bands are packed into the SIMD lanes in the | |
| /// wrong order, every downstream weight is driven by the wrong part of | |
| /// the spectrum. Nothing about that is visible from range checks: the | |
| /// weights stay in `[0, 1]`, they move with the audio, and the mouth is | |
| /// simply wrong. Testing tone-to-band correspondence directly is the | |
| /// only way to catch it. | |
| fn each_band_responds_most_to_its_own_centre_frequency() { | |
| for (band, &(lo, hi)) in BAND_EDGES_HZ.iter().enumerate() { | |
| let centre = (lo * hi).sqrt(); | |
| let mut bank = BandBank::new(); | |
| // Long enough for the resonators to reach steady state. | |
| let tone = sine(centre, 0.5, 4096, 0.0); | |
| let energies = bank.analyse(&tone); | |
| let winner = energies | |
| .iter() | |
| .enumerate() | |
| .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) | |
| .map(|(i, _)| i) | |
| .unwrap(); | |
| assert_eq!( | |
| winner, band, | |
| "a {centre:.0} Hz tone (centre of band {band}, {lo}-{hi} Hz) \ | |
| produced peak energy in band {winner} instead. Band \ | |
| energies: {energies:?}" | |
| ); | |
| } | |
| } | |
| /// Bands must also *reject* distant frequencies, not merely prefer | |
| /// their own. A bank where every band passes everything would still | |
| /// win the test above while carrying no spectral information at all. | |
| fn bands_reject_distant_frequencies() { | |
| let mut bank = BandBank::new(); | |
| let tone = sine(200.0, 0.5, 4096, 0.0); | |
| let e = bank.analyse(&tone); | |
| assert!( | |
| e[B_SIB_HIGH] < e[B_VOICING] * 0.05, | |
| "a 200 Hz tone leaked {} into the 5.5-7.8 kHz band against {} \ | |
| in its own band β the bank is not selective", | |
| e[B_SIB_HIGH], | |
| e[B_VOICING] | |
| ); | |
| let mut bank = BandBank::new(); | |
| let tone = sine(6500.0, 0.5, 4096, 0.0); | |
| let e = bank.analyse(&tone); | |
| assert!( | |
| e[B_VOICING] < e[B_SIB_HIGH] * 0.05, | |
| "a 6.5 kHz tone leaked {} into the 100-300 Hz band", | |
| e[B_VOICING] | |
| ); | |
| } | |
| /// Pathological input must not destabilise the recursive filters. A | |
| /// biquad that blows up produces infinities that, once written to the | |
| /// ring buffer, break the face permanently rather than for one frame. | |
| fn bank_is_stable_under_pathological_input() { | |
| let cases: Vec<(&str, Vec<f32>)> = vec![ | |
| ("dc", vec![1.0; 4096]), | |
| ( | |
| "nyquist square", | |
| (0..4096).map(|i| if i % 2 == 0 { 1.0 } else { -1.0 }).collect(), | |
| ), | |
| ("full scale step", { | |
| let mut v = vec![0.0; 4096]; | |
| v[2048..].fill(1.0); | |
| v | |
| }), | |
| ("loud", vec![100.0; 4096]), | |
| ]; | |
| for (name, input) in cases { | |
| let mut bank = BandBank::new(); | |
| let e = bank.analyse(&input); | |
| for (i, v) in e.iter().enumerate() { | |
| assert!( | |
| v.is_finite(), | |
| "{name}: band {i} energy is not finite ({v}) β filter diverged" | |
| ); | |
| } | |
| } | |
| } | |
| fn empty_input_is_handled_without_panicking() { | |
| let mut bank = BandBank::new(); | |
| assert_eq!(bank.analyse(&[]), [0.0; BAND_COUNT]); | |
| assert_eq!(rms(&[]), 0.0); | |
| } | |
| /// A block whose length is not a multiple of four must be fully | |
| /// accounted for. `chunks_exact` silently drops the remainder, so a | |
| /// missing tail loop is easy to write and hard to notice β the error is | |
| /// a small level offset, not a crash. | |
| fn rms_includes_the_non_multiple_of_four_tail() { | |
| // Nine samples: two full vectors and a one-sample remainder. | |
| let mut v = vec![0.0f32; 9]; | |
| v[8] = 3.0; | |
| let expected = (9.0f32 / 9.0).sqrt(); | |
| assert!( | |
| (rms(&v) - expected).abs() < 1e-6, | |
| "tail sample was dropped: got {}, expected {expected}", | |
| rms(&v) | |
| ); | |
| } | |
| fn rms_matches_the_scalar_definition() { | |
| let block = vowel(730.0, 1090.0, 0.4, 267); | |
| let scalar = | |
| (block.iter().map(|x| x * x).sum::<f32>() / block.len() as f32).sqrt(); | |
| assert!( | |
| (rms(&block) - scalar).abs() < 1e-5, | |
| "SIMD rms {} disagrees with scalar {}", | |
| rms(&block), | |
| scalar | |
| ); | |
| } | |
| // ----------------------------------------------------------------- | |
| // Drive curve | |
| // ----------------------------------------------------------------- | |
| fn silence_produces_zero_drive_and_loud_speech_produces_full_drive() { | |
| assert_eq!(drive_from_rms(0.0), 0.0); | |
| // -80 dBFS, below the floor. | |
| assert_eq!(drive_from_rms(0.0001), 0.0); | |
| // 0 dBFS, above full drive. | |
| assert_eq!(drive_from_rms(1.0), 1.0); | |
| // Conversational speech around -30 dBFS should land mid-range, not | |
| // pinned at either end. A linear (non-dB) map would put this at | |
| // about 0.03 and the mouth would barely move. | |
| let d = drive_from_rms(10f32.powf(-30.0 / 20.0)); | |
| assert!( | |
| (0.3..0.8).contains(&d), | |
| "-30 dBFS mapped to drive {d}, outside a usable mid-range" | |
| ); | |
| } | |
| fn drive_is_monotonic_in_level() { | |
| let mut prev = -1.0; | |
| for i in 0..=100 { | |
| let dbfs = -70.0 + i as f32 * 0.7; | |
| let d = drive_from_rms(10f32.powf(dbfs / 20.0)); | |
| assert!(d >= prev, "drive fell from {prev} to {d} at {dbfs} dBFS"); | |
| prev = d; | |
| } | |
| } | |
| // ----------------------------------------------------------------- | |
| // Solver behaviour | |
| // ----------------------------------------------------------------- | |
| fn silence_produces_a_rest_pose() { | |
| let mut solver = AudioBlendshapeSolver::new(); | |
| let out = settle(&mut solver, &vec![0.0; FRAME_SAMPLES], 60); | |
| for (i, w) in out.iter().enumerate() { | |
| assert_eq!(*w, 0.0, "channel {i} = {w} during silence"); | |
| } | |
| } | |
| /// Layer ownership, enforced. Speech owns the mouth; the autonomic | |
| /// oscillators own eyes, brows, cheeks and nose. If the solver ever | |
| /// wrote an eye channel it would fight the blink and gaze generators | |
| /// through the compositor's additive sum, and the result would look | |
| /// like a rendering glitch rather than a layering bug. | |
| fn solver_never_touches_autonomic_channels() { | |
| let forbidden = [ | |
| arkit::EYE_BLINK_LEFT, | |
| arkit::EYE_BLINK_RIGHT, | |
| arkit::EYE_SQUINT_LEFT, | |
| arkit::EYE_SQUINT_RIGHT, | |
| arkit::EYE_LOOK_IN_LEFT, | |
| arkit::EYE_LOOK_OUT_LEFT, | |
| arkit::EYE_LOOK_UP_LEFT, | |
| arkit::EYE_LOOK_DOWN_LEFT, | |
| arkit::EYE_LOOK_IN_RIGHT, | |
| arkit::EYE_LOOK_OUT_RIGHT, | |
| arkit::EYE_LOOK_UP_RIGHT, | |
| arkit::EYE_LOOK_DOWN_RIGHT, | |
| arkit::EYE_WIDE_LEFT, | |
| arkit::EYE_WIDE_RIGHT, | |
| arkit::BROW_DOWN_LEFT, | |
| arkit::BROW_DOWN_RIGHT, | |
| arkit::BROW_INNER_UP, | |
| arkit::BROW_OUTER_UP_LEFT, | |
| arkit::BROW_OUTER_UP_RIGHT, | |
| arkit::CHEEK_PUFF, | |
| arkit::CHEEK_SQUINT_LEFT, | |
| arkit::CHEEK_SQUINT_RIGHT, | |
| arkit::NOSE_SNEER_LEFT, | |
| arkit::NOSE_SNEER_RIGHT, | |
| // Never inferred from audio: nothing about band energy | |
| // distinguishes tongue position behind closed lips. | |
| arkit::TONGUE_OUT, | |
| ]; | |
| let inputs: Vec<Vec<f32>> = vec![ | |
| vowel(730.0, 1090.0, 0.5, FRAME_SAMPLES), | |
| vowel(270.0, 2290.0, 0.5, FRAME_SAMPLES), | |
| noise_band(5000.0, 7500.0, 0.5, FRAME_SAMPLES), | |
| noise_band(80.0, 8000.0, 0.5, FRAME_SAMPLES), | |
| vec![0.0; FRAME_SAMPLES], | |
| ]; | |
| let mut solver = AudioBlendshapeSolver::new(); | |
| for input in &inputs { | |
| for _ in 0..30 { | |
| let out = solver.solve(input); | |
| for &c in forbidden.iter() { | |
| assert_eq!( | |
| out[c], 0.0, | |
| "solver wrote {} to autonomic channel {c}", | |
| out[c] | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| fn weights_stay_in_unit_range_for_every_input() { | |
| let inputs: Vec<Vec<f32>> = vec![ | |
| vowel(730.0, 1090.0, 1.0, FRAME_SAMPLES), | |
| vowel(270.0, 2290.0, 1.0, FRAME_SAMPLES), | |
| vowel(300.0, 870.0, 1.0, FRAME_SAMPLES), | |
| noise_band(3600.0, 7800.0, 1.0, FRAME_SAMPLES), | |
| noise_band(80.0, 8000.0, 1.0, FRAME_SAMPLES), | |
| // Deliberately over unity β a clipped capture is a real thing | |
| // that happens, and it must not produce out-of-range weights. | |
| vec![50.0; FRAME_SAMPLES], | |
| ]; | |
| let mut solver = AudioBlendshapeSolver::new(); | |
| for (n, input) in inputs.iter().enumerate() { | |
| for _ in 0..40 { | |
| let out = solver.solve(input); | |
| for (i, w) in out.iter().enumerate() { | |
| assert!( | |
| (0.0..=1.0).contains(w), | |
| "input {n}: channel {i} = {w} out of range" | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| /// Loud input must not drive every channel to 1.0. A solver that | |
| /// saturates is producing an expressionless rictus, which passes every | |
| /// range check while carrying zero information about the speech. | |
| fn loud_input_does_not_saturate_every_channel() { | |
| let mut solver = AudioBlendshapeSolver::new(); | |
| let out = settle( | |
| &mut solver, | |
| &noise_band(80.0, 8000.0, 0.9, FRAME_SAMPLES), | |
| 60, | |
| ); | |
| let saturated = out.iter().filter(|w| **w > 0.99).count(); | |
| assert!( | |
| saturated <= 2, | |
| "{saturated} channels saturated at 1.0 on loud broadband input β \ | |
| the basis is not discriminating" | |
| ); | |
| } | |
| /// **Open versus close vowel.** /a/ (F1 730) must open the jaw more | |
| /// than /i/ (F1 270). This is the single most visible property of | |
| /// audio-driven lip sync, and it is what a band-order or F1/F2 mix-up | |
| /// would break. | |
| fn open_vowel_opens_the_jaw_more_than_a_close_vowel() { | |
| let mut s = AudioBlendshapeSolver::new(); | |
| let ah = settle(&mut s, &vowel(730.0, 1090.0, 0.5, FRAME_SAMPLES), 60) | |
| [arkit::JAW_OPEN]; | |
| let mut s = AudioBlendshapeSolver::new(); | |
| let ee = settle(&mut s, &vowel(270.0, 2290.0, 0.5, FRAME_SAMPLES), 60) | |
| [arkit::JAW_OPEN]; | |
| println!("jawOpen: /a/ = {ah:.4}, /i/ = {ee:.4}"); | |
| assert!( | |
| ah > ee * 1.5, | |
| "/a/ (F1 730) gave jawOpen {ah} but /i/ (F1 270) gave {ee} β the \ | |
| jaw is not tracking F1" | |
| ); | |
| assert!(ah > 0.15, "/a/ barely opened the jaw ({ah}) β visually dead"); | |
| } | |
| /// **Rounded versus spread.** /u/ must round the lips and /i/ must | |
| /// spread them, and each must suppress the other. Driving both on every | |
| /// vowel is the failure the inhibitory basis weights exist to prevent. | |
| fn rounded_and_spread_vowels_produce_opposite_lip_shapes() { | |
| let mut s = AudioBlendshapeSolver::new(); | |
| // /u/ β F1 300, F2 870: both formants low. | |
| let oo = settle(&mut s, &vowel(300.0, 870.0, 0.5, FRAME_SAMPLES), 60); | |
| let mut s = AudioBlendshapeSolver::new(); | |
| // /i/ β F1 270, F2 2290: F2 far up. | |
| let ee = settle(&mut s, &vowel(270.0, 2290.0, 0.5, FRAME_SAMPLES), 60); | |
| println!( | |
| "/u/: funnel {:.4} pucker {:.4} smile {:.4}", | |
| oo[arkit::MOUTH_FUNNEL], | |
| oo[arkit::MOUTH_PUCKER], | |
| oo[arkit::MOUTH_SMILE_LEFT] | |
| ); | |
| println!( | |
| "/i/: funnel {:.4} pucker {:.4} smile {:.4}", | |
| ee[arkit::MOUTH_FUNNEL], | |
| ee[arkit::MOUTH_PUCKER], | |
| ee[arkit::MOUTH_SMILE_LEFT] | |
| ); | |
| assert!( | |
| oo[arkit::MOUTH_PUCKER] > oo[arkit::MOUTH_SMILE_LEFT], | |
| "/u/ smiled ({}) more than it puckered ({})", | |
| oo[arkit::MOUTH_SMILE_LEFT], | |
| oo[arkit::MOUTH_PUCKER] | |
| ); | |
| assert!( | |
| ee[arkit::MOUTH_SMILE_LEFT] > ee[arkit::MOUTH_PUCKER], | |
| "/i/ puckered ({}) more than it smiled ({})", | |
| ee[arkit::MOUTH_PUCKER], | |
| ee[arkit::MOUTH_SMILE_LEFT] | |
| ); | |
| assert!( | |
| ee[arkit::MOUTH_SMILE_LEFT] > 0.1, | |
| "/i/ barely spread the lips ({}) β visually dead", | |
| ee[arkit::MOUTH_SMILE_LEFT] | |
| ); | |
| } | |
| /// Unvoiced friction must engage the lip/teeth channels rather than | |
| /// dropping the jaw. A solver keyed on loudness alone gapes at every | |
| /// /s/. | |
| fn sibilance_engages_lips_rather_than_opening_the_jaw() { | |
| let mut s = AudioBlendshapeSolver::new(); | |
| let ess = settle( | |
| &mut s, | |
| &noise_band(5000.0, 7500.0, 0.5, FRAME_SAMPLES), | |
| 60, | |
| ); | |
| println!( | |
| "/s/: jawOpen {:.4} shrugLower {:.4} rollLower {:.4}", | |
| ess[arkit::JAW_OPEN], | |
| ess[arkit::MOUTH_SHRUG_LOWER], | |
| ess[arkit::MOUTH_ROLL_LOWER] | |
| ); | |
| assert!( | |
| ess[arkit::MOUTH_SHRUG_LOWER] > ess[arkit::JAW_OPEN], | |
| "/s/ opened the jaw ({}) more than it engaged the lips ({})", | |
| ess[arkit::JAW_OPEN], | |
| ess[arkit::MOUTH_SHRUG_LOWER] | |
| ); | |
| } | |
| /// Voiced low-frequency energy with nothing above it is bilabial | |
| /// closure, and must close rather than open the mouth. | |
| fn voiced_low_energy_reads_as_closure_not_aperture() { | |
| let mut s = AudioBlendshapeSolver::new(); | |
| let mm = settle(&mut s, &sine(150.0, 0.5, FRAME_SAMPLES, 0.0), 60); | |
| println!( | |
| "/m/: mouthClose {:.4} jawOpen {:.4}", | |
| mm[arkit::MOUTH_CLOSE], | |
| mm[arkit::JAW_OPEN] | |
| ); | |
| assert!( | |
| mm[arkit::MOUTH_CLOSE] > mm[arkit::JAW_OPEN], | |
| "150 Hz voicing gave mouthClose {} vs jawOpen {} β closure is not \ | |
| being detected", | |
| mm[arkit::MOUTH_CLOSE], | |
| mm[arkit::JAW_OPEN] | |
| ); | |
| } | |
| // ----------------------------------------------------------------- | |
| // Temporal behaviour | |
| // ----------------------------------------------------------------- | |
| /// Attack must be faster than release. Symmetric smoothing makes the | |
| /// mouth snap shut between syllables β the classic cheap-lip-sync | |
| /// chatter. | |
| fn attack_is_faster_than_release() { | |
| let loud = vowel(730.0, 1090.0, 0.5, FRAME_SAMPLES); | |
| let quiet = vec![0.0f32; FRAME_SAMPLES]; | |
| let mut s = AudioBlendshapeSolver::new(); | |
| // Reach steady state, note it, then measure how many frames it took | |
| // to get most of the way there from rest. | |
| let settled = settle(&mut s, &loud, 80)[arkit::JAW_OPEN]; | |
| assert!(settled > 0.1, "no signal to measure ({settled})"); | |
| let mut s = AudioBlendshapeSolver::new(); | |
| let mut rise_frames = 0; | |
| for f in 1..=200 { | |
| if s.solve(&loud)[arkit::JAW_OPEN] >= settled * 0.9 { | |
| rise_frames = f; | |
| break; | |
| } | |
| } | |
| let mut fall_frames = 0; | |
| for f in 1..=200 { | |
| if s.solve(&quiet)[arkit::JAW_OPEN] <= settled * 0.1 { | |
| fall_frames = f; | |
| break; | |
| } | |
| } | |
| println!("jawOpen rise {rise_frames} frames, fall {fall_frames} frames"); | |
| assert!(rise_frames > 0 && fall_frames > 0, "never settled either way"); | |
| assert!( | |
| fall_frames > rise_frames, | |
| "release ({fall_frames} frames) is not slower than attack \ | |
| ({rise_frames} frames)" | |
| ); | |
| } | |
| /// A steady input must reach a steady output. A solver that never | |
| /// settles has replaced silence with jitter. | |
| fn steady_input_converges_to_a_steady_output() { | |
| let mut s = AudioBlendshapeSolver::new(); | |
| let block = vowel(730.0, 1090.0, 0.5, FRAME_SAMPLES); | |
| let settled = settle(&mut s, &block, 120); | |
| for _ in 0..30 { | |
| let now = *s.solve(&block); | |
| for i in 0..BLENDSHAPE_COUNT { | |
| assert!( | |
| (now[i] - settled[i]).abs() < 1e-3, | |
| "channel {i} drifted from {} to {}", | |
| settled[i], | |
| now[i] | |
| ); | |
| } | |
| } | |
| } | |
| /// Reproducibility is required by T9's capture workflow: the same audio | |
| /// must yield the same weights. | |
| fn solving_is_deterministic_after_reset() { | |
| let block = vowel(500.0, 1500.0, 0.4, FRAME_SAMPLES); | |
| let mut s = AudioBlendshapeSolver::new(); | |
| let first = settle(&mut s, &block, 50); | |
| s.reset(); | |
| let second = settle(&mut s, &block, 50); | |
| assert_eq!(first, second, "solver output is not reproducible after reset"); | |
| } | |
| fn reset_clears_filter_ringing_between_utterances() { | |
| let mut s = AudioBlendshapeSolver::new(); | |
| settle(&mut s, &vowel(730.0, 1090.0, 0.8, FRAME_SAMPLES), 40); | |
| s.reset(); | |
| for (i, w) in s.weights().iter().enumerate() { | |
| assert_eq!(*w, 0.0, "channel {i} survived reset with {w}"); | |
| } | |
| assert_eq!(s.features().drive, 0.0); | |
| } | |
| fn add_into_is_additive_not_assigning() { | |
| let mut s = AudioBlendshapeSolver::new(); | |
| settle(&mut s, &vowel(730.0, 1090.0, 0.5, FRAME_SAMPLES), 60); | |
| let mut out = [0.0f32; BLENDSHAPE_COUNT]; | |
| out[arkit::JAW_OPEN] = 0.04; // as the breath oscillator would leave it | |
| let before = out[arkit::JAW_OPEN]; | |
| s.add_into(&mut out); | |
| assert!( | |
| out[arkit::JAW_OPEN] > before, | |
| "add_into clobbered an existing contribution ({before} -> {})", | |
| out[arkit::JAW_OPEN] | |
| ); | |
| assert!( | |
| (out[arkit::JAW_OPEN] - (before + s.weights()[arkit::JAW_OPEN])).abs() < 1e-6 | |
| ); | |
| } | |
| fn solver_is_send() { | |
| fn assert_send<T: Send>() {} | |
| assert_send::<AudioBlendshapeSolver>(); | |
| assert_send::<BandBank>(); | |
| } | |
| // ----------------------------------------------------------------- | |
| // Performance budget | |
| // ----------------------------------------------------------------- | |
| /// **Acceptance criterion: solve time under 0.2 ms.** | |
| /// | |
| /// Measured on a real block of one frame's worth of audio (267 samples | |
| /// at 16 kHz), averaged over many iterations to get above timer | |
| /// resolution. | |
| /// | |
| /// The assertion is gated on `debug_assertions` because an unoptimised | |
| /// build is 10-40x slower and the number would say nothing about the | |
| /// shipped code. In a debug build the measurement is printed and the | |
| /// budget is not enforced; in release it is enforced. Run | |
| /// `cargo test --release -p miranda-nodes solve_time` for the number | |
| /// that counts. | |
| fn solve_time_is_within_the_frame_budget() { | |
| const BUDGET_US: f64 = 200.0; | |
| const ITERS: usize = 20_000; | |
| let block = vowel(730.0, 1090.0, 0.5, FRAME_SAMPLES); | |
| let mut s = AudioBlendshapeSolver::new(); | |
| // Warm up so the measurement is not dominated by first-touch page | |
| // faults and cold caches. | |
| for _ in 0..2_000 { | |
| s.solve(&block); | |
| } | |
| let start = Instant::now(); | |
| for _ in 0..ITERS { | |
| let out = s.solve(&block); | |
| // Consume the result so the loop cannot be optimised away. | |
| std::hint::black_box(out[arkit::JAW_OPEN]); | |
| } | |
| let per_solve_us = start.elapsed().as_secs_f64() * 1e6 / ITERS as f64; | |
| println!( | |
| "solve: {per_solve_us:.3} us/frame for {FRAME_SAMPLES} samples \ | |
| ({BAND_COUNT} bands, f32x4) β budget {BUDGET_US} us, \ | |
| {}% of a 16.67 ms frame", | |
| (per_solve_us / 16_670.0 * 100.0).round() | |
| ); | |
| if cfg!(debug_assertions) { | |
| println!( | |
| "NOTE: debug build β budget NOT enforced. Run with --release \ | |
| for the number that counts." | |
| ); | |
| } else { | |
| assert!( | |
| per_solve_us < BUDGET_US, | |
| "solve took {per_solve_us:.3} us, over the {BUDGET_US} us budget" | |
| ); | |
| } | |
| } | |
| } | |