Skip to main content

eui_core/
voice_wave.rs

1//! NEW-77: the VoiceWave rings (catalog/style.json `voiceWave`), a port of
2//! `src/voice-wave.ts`; `fixtures/voice-wave.json` locks it. A painter fills
3//! and strokes each ring's closed path with its part colour (`outer`, `mid`,
4//! `inner`) at the ring's alphas and centres the child at `child_size`.
5
6use std::f64::consts::PI;
7use std::sync::LazyLock;
8
9use serde::{Deserialize, Serialize};
10
11use crate::generated::catalog as g;
12
13#[derive(Debug, Clone, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct VoiceWaveSpec {
16    pub steps: usize,
17    pub radius: Vec<f64>,
18    pub harmonics: Vec<Vec<f64>>,
19    pub phase0: Vec<f64>,
20    pub ring_amp: Vec<f64>,
21    pub amp_base: f64,
22    pub amp_level: f64,
23    pub thinking_amp: f64,
24    pub phase_speed: f64,
25    pub fill_alpha: Vec<f64>,
26    pub stroke_alpha: Vec<f64>,
27    pub stroke_width: Vec<f64>,
28    pub child_scale: f64,
29    pub child_scale_large: f64,
30    pub max_bands: usize,
31}
32
33pub static VOICE_WAVE: LazyLock<VoiceWaveSpec> = LazyLock::new(|| {
34    let style: serde_json::Value = serde_json::from_str(g::STYLE_JSON).expect("style.json");
35    serde_json::from_value(style["voiceWave"].clone()).expect("style.json voiceWave")
36});
37
38/// The ring parts, outermost first.
39pub const RINGS: [&str; 3] = ["outer", "mid", "inner"];
40
41/// The `activity` prop (catalog enum `voiceWaveActivity`).
42/// Deserialized through [`Activity::parse`]: every value the catalog does
43/// not name (a bound string, a typo) is `Idle`, the TS reference's fallback,
44/// so no FFI consumer gets `{}`.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase", from = "String")]
47pub enum Activity {
48    #[default]
49    Idle,
50    Listening,
51    Thinking,
52    Speaking,
53    Muted,
54}
55
56impl From<String> for Activity {
57    fn from(s: String) -> Self {
58        Self::parse(&s)
59    }
60}
61
62impl Activity {
63    pub fn parse(s: &str) -> Self {
64        match s {
65            "listening" => Self::Listening,
66            "thinking" => Self::Thinking,
67            "speaking" => Self::Speaking,
68            "muted" => Self::Muted,
69            _ => Self::Idle,
70        }
71    }
72
73    /// The catalog's name of the activity (what [`Activity::parse`] reads).
74    pub fn as_str(self) -> &'static str {
75        match self {
76            Self::Idle => "idle",
77            Self::Listening => "listening",
78            Self::Thinking => "thinking",
79            Self::Speaking => "speaking",
80            Self::Muted => "muted",
81        }
82    }
83
84    /// Whether the wave moves (a painter keeps requesting frames).
85    pub fn animates(self) -> bool {
86        matches!(self, Self::Listening | Self::Thinking | Self::Speaking)
87    }
88}
89
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct VoiceWaveInput {
93    pub size: f64,
94    pub activity: Activity,
95    #[serde(default)]
96    pub level: Option<f64>,
97    #[serde(default)]
98    pub bands: Option<Vec<f64>>,
99    #[serde(default)]
100    pub elapsed: Option<f64>,
101    #[serde(default)]
102    pub reduced_motion: Option<bool>,
103    /// The one child's own laid-out size (the core centres it at that size).
104    #[serde(default)]
105    pub child: Option<ChildSize>,
106}
107
108#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
109pub struct ChildSize {
110    pub width: f64,
111    pub height: f64,
112}
113
114/// Where a painter draws the child: its own box scaled about the wave's
115/// centre by `scale` = min(1, child_size / its longer edge), never up;
116/// 0 = kept in the tree but not painted (`sm`).
117#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
118pub struct ChildBox {
119    pub x: f64,
120    pub y: f64,
121    pub width: f64,
122    pub height: f64,
123    pub scale: f64,
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct VoiceWaveRing {
129    pub part: String,
130    /// `steps + 1` points in px from the box's top-left; the last repeats the first.
131    pub points: Vec<[f64; 2]>,
132    pub fill_alpha: f64,
133    pub stroke_alpha: f64,
134    pub stroke_width: f64,
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct VoiceWave {
140    pub rings: Vec<VoiceWaveRing>,
141    /// The centre child's edge in px; 0 = not painted (`sm`).
142    pub child_size: f64,
143    pub phase: f64,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub child: Option<ChildBox>,
146}
147
148fn clamp01(n: f64) -> f64 {
149    if n.is_finite() { n.clamp(0.0, 1.0) } else { 0.0 }
150}
151
152fn round2(n: f64) -> f64 {
153    // JS Math.round (half up): every value here is >= 0 except a stray
154    // negative coordinate, where floor(x + 0.5) keeps the two equal.
155    (n * 100.0 + 0.5).floor() / 100.0
156}
157
158/// The phase (radians) a painter draws at `elapsed` seconds.
159pub fn phase(activity: Activity, elapsed: f64, reduced_motion: bool) -> f64 {
160    if reduced_motion || !activity.animates() {
161        return 0.0;
162    }
163    let e = if elapsed.is_finite() { elapsed.max(0.0) } else { 0.0 };
164    e * VOICE_WAVE.phase_speed
165}
166
167/// The child's edge for a box `size` (sm 32 · md 56 · lg 160 · xl 200).
168pub fn child_size(size: f64) -> f64 {
169    let v = &*VOICE_WAVE;
170    if size < 40.0 {
171        return 0.0;
172    }
173    round2(size * if size >= 100.0 { v.child_scale_large } else { v.child_scale })
174}
175
176/// The child's painted box in a wave of edge `size` (see [`ChildBox`]).
177pub fn child_box(size: f64, child: ChildSize) -> ChildBox {
178    let edge = child_size(size);
179    let (w, h) = (child.width.max(0.0), child.height.max(0.0));
180    let longer = w.max(h);
181    let scale = if edge == 0.0 {
182        0.0
183    } else if longer <= edge || longer == 0.0 {
184        1.0
185    } else {
186        (edge / longer * 10000.0).round() / 10000.0
187    };
188    let (width, height) = (round2(w * scale), round2(h * scale));
189    ChildBox { x: round2((size - width) / 2.0), y: round2((size - height) / 2.0), width, height, scale }
190}
191
192/// The three rings for one frame (coordinates rounded to 0.01 px).
193pub fn voice_wave(input: &VoiceWaveInput) -> VoiceWave {
194    let v = &*VOICE_WAVE;
195    let size = input.size.max(0.0);
196    let c = size / 2.0;
197    let level = clamp01(input.level.unwrap_or(0.0));
198    let bands: Vec<f64> = input.bands.as_deref().unwrap_or(&[]).iter().take(v.max_bands).map(|b| clamp01(*b)).collect();
199    let ph = phase(input.activity, input.elapsed.unwrap_or(0.0), input.reduced_motion.unwrap_or(false));
200    let drive = match input.activity {
201        Activity::Listening | Activity::Speaking => v.amp_base + v.amp_level * level,
202        Activity::Thinking => v.thinking_amp,
203        Activity::Idle | Activity::Muted => 0.0,
204    };
205    let flat = bands.is_empty() || input.activity == Activity::Thinking;
206    let rings = RINGS
207        .iter()
208        .enumerate()
209        .map(|(k, part)| {
210            let amp = v.ring_amp[k] * size * drive / 200.0;
211            let points = (0..=v.steps)
212                .map(|i| {
213                    let t = i as f64 / v.steps as f64 * PI * 2.0;
214                    let mut r = size * v.radius[k];
215                    for (j, h) in v.harmonics[k].iter().enumerate() {
216                        let band = if flat { 1.0 } else { bands[(3 * k + j) % bands.len()] };
217                        r += amp / (j as f64 + 1.0) * band * (h * t + (v.phase0[k] + ph) * (j as f64 + 1.0)).sin();
218                    }
219                    [round2(c + r * t.cos()), round2(c + r * t.sin())]
220                })
221                .collect();
222            VoiceWaveRing {
223                part: (*part).to_string(),
224                points,
225                fill_alpha: v.fill_alpha[k],
226                stroke_alpha: v.stroke_alpha[k],
227                stroke_width: round2((v.stroke_width[k] * size / 200.0).max(1.0)),
228            }
229        })
230        .collect();
231    VoiceWave { rings, child_size: child_size(size), phase: round2(ph), child: input.child.map(|c| child_box(size, c)) }
232}
233
234
235// ---------------------------------------------------------------------------
236// VAPP-162: `variant: bars`, the horizontal level meter (style.json
237// `voiceWave.bars`, src/voice-wave.ts voiceWaveBars). Same inputs as the
238// rings; no child (a child stays in the tree, unpainted).
239// ---------------------------------------------------------------------------
240
241#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct VoiceWaveBarsSize {
244    pub height: f64,
245    pub bar_width: f64,
246    pub gap: f64,
247}
248
249#[derive(Debug, Clone, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub struct VoiceWaveBarsSpec {
252    pub count: usize,
253    pub min_fraction: f64,
254    pub envelope: f64,
255    pub shape_freq: f64,
256    pub shape_base: f64,
257    pub sizes: indexmap::IndexMap<String, VoiceWaveBarsSize>,
258}
259
260pub static VOICE_WAVE_BARS: LazyLock<VoiceWaveBarsSpec> = LazyLock::new(|| {
261    let style: serde_json::Value = serde_json::from_str(g::STYLE_JSON).expect("style.json");
262    serde_json::from_value(style["voiceWave"]["bars"].clone()).expect("style.json voiceWave.bars")
263});
264
265/// The `variant` prop (catalog enum): anything but `bars` is `ring`.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase", from = "String")]
268pub enum Variant {
269    #[default]
270    Ring,
271    Bars,
272}
273
274impl From<String> for Variant {
275    fn from(s: String) -> Self {
276        Self::parse(&s)
277    }
278}
279
280impl Variant {
281    pub fn parse(s: &str) -> Self {
282        if s == "bars" { Self::Bars } else { Self::Ring }
283    }
284}
285
286/// The input of [`voice_wave_bars`]: `size` = the size CLASS (`sm` `md`
287/// `lg` `xl`; anything else is `md`).
288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct VoiceWaveBarsInput {
291    pub size: String,
292    pub activity: Activity,
293    #[serde(default)]
294    pub level: Option<f64>,
295    #[serde(default)]
296    pub bands: Option<Vec<f64>>,
297    #[serde(default)]
298    pub elapsed: Option<f64>,
299    #[serde(default)]
300    pub reduced_motion: Option<bool>,
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
304pub struct VoiceWaveBar {
305    pub x: f64,
306    pub y: f64,
307    pub width: f64,
308    pub height: f64,
309    pub radius: f64,
310}
311
312/// One frame of the meter: the root box (count × barWidth + (count − 1) ×
313/// gap wide, `height` tall), the phase and every bar rect (px from the
314/// box's top-left), filled with the `bar` part colour.
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
316pub struct VoiceWaveBars {
317    pub width: f64,
318    pub height: f64,
319    pub phase: f64,
320    pub bars: Vec<VoiceWaveBar>,
321}
322
323/// The box of a `bars` meter for a size class: (width, height, bar width, gap).
324pub fn voice_wave_bars_box(size: &str) -> (f64, f64, f64, f64) {
325    let b = &*VOICE_WAVE_BARS;
326    let spec = b.sizes.get(size).or_else(|| b.sizes.get("md")).copied().expect("style.json voiceWave.bars.sizes.md");
327    let n = b.count as f64;
328    (n * spec.bar_width + (n - 1.0) * spec.gap, spec.height, spec.bar_width, spec.gap)
329}
330
331/// The bars for one frame (style.json `voiceWave.bars` $comment).
332pub fn voice_wave_bars(input: &VoiceWaveBarsInput) -> VoiceWaveBars {
333    let v = &*VOICE_WAVE;
334    let b = &*VOICE_WAVE_BARS;
335    let (width, height, bar_width, gap) = voice_wave_bars_box(&input.size);
336    let level = clamp01(input.level.unwrap_or(0.0));
337    let bands: Vec<f64> = input.bands.as_deref().unwrap_or(&[]).iter().take(v.max_bands).map(|b| clamp01(*b)).collect();
338    let ph = phase(input.activity, input.elapsed.unwrap_or(0.0), input.reduced_motion.unwrap_or(false));
339    let full = v.amp_base + v.amp_level;
340    let drive = match input.activity {
341        Activity::Listening | Activity::Speaking => (v.amp_base + v.amp_level * level) / full,
342        Activity::Thinking => v.thinking_amp / full,
343        Activity::Idle | Activity::Muted => 0.0,
344    };
345    let flat = bands.is_empty() || input.activity == Activity::Thinking;
346    let n = b.count;
347    let bars = (0..n)
348        .map(|i| {
349            let fi = i as f64;
350            let band = if flat { 1.0 } else { bands[i % bands.len()] };
351            let envelope = 1.0 - b.envelope * ((2.0 * fi) / (n as f64 - 1.0) - 1.0).abs();
352            let shape = 0.5 + 0.5 * (b.shape_freq * fi + ph * (1.0 + (i % 3) as f64 * 0.5)).sin();
353            let fraction = b.min_fraction + (1.0 - b.min_fraction) * drive * band * envelope * (b.shape_base + (1.0 - b.shape_base) * shape);
354            let h = round2(height * fraction);
355            VoiceWaveBar { x: fi * (bar_width + gap), y: round2((height - h) / 2.0), width: bar_width, height: h, radius: bar_width / 2.0 }
356        })
357        .collect();
358    VoiceWaveBars { width, height, phase: round2(ph), bars }
359}