1use 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
38pub const RINGS: [&str; 3] = ["outer", "mid", "inner"];
40
41#[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 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 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 #[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#[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 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 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 (n * 100.0 + 0.5).floor() / 100.0
156}
157
158pub 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
167pub 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
176pub 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
192pub 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#[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#[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#[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#[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
323pub 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
331pub 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}