tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! `llimphi-widget-waveform` — visor de **forma de onda en vivo**.
2//!
3//! Pattern análogo a [`llimphi-widget-timeline`](
4//! https://docs.rs/llimphi-widget-timeline): el widget **no mantiene
5//! estado** del audio (ni cpal, ni `AudioProbe`, ni ringbuffer). El caller
6//! le pasa un closure `Fn(&mut Vec<f32>) -> u16` que rellena un buffer con
7//! los últimos samples y devuelve cuántos **canales** intercalados trae;
8//! el widget hace el fold a mono y dibuja un **envelope min/max por
9//! columna** (polígono cerrado con relleno tenue + stroke por arriba y
10//! por abajo) sobre una **línea central** que siempre está presente como
11//! "ground" del visor. Sin handlers de mouse — paint-only.
12//!
13//! ```text
14//! ┌─────────────────────────────────────────────────┐
15//! │ ▄▄▄ ▄ ▄▄▄ ▄▄ ▄▄ ▄ │
16//! │ ▄███▄ ▄█▄▄███▄▄██▄▄▄▄██▄▄█▄ │
17//! │──█████──███████████████████████─── centro ──────│
18//! │ ▀███▀ ▀█▀▀███▀▀██▀▀▀▀██▀▀█▀ │
19//! │ ▀▀▀ ▀ ▀▀▀ ▀▀ ▀▀ ▀ │
20//! └─────────────────────────────────────────────────┘
21//! ```
22//!
23//! Uso típico (reproductor con audio probe):
24//!
25//! ```ignore
26//! use alloc::sync::Arc;
27//! let probe = audio_probe(); // Arc<AudioProbe> propia de la app
28//! let palette = WaveformPalette::default();
29//! waveform_view(
30//! move |out| {
31//! let (_sr, ch) = probe.snapshot(out);
32//! ch // canales intercalados
33//! },
34//! &palette,
35//! )
36//! ```
37//!
38//! Si el closure devuelve `0` canales (o no llena el buffer), el widget
39//! pinta sólo la línea central — útil para mostrar "visor vivo, sin
40//! señal" cuando el dispositivo de captura todavía no levantó.
41
42#![forbid(unsafe_code)]
43
44
45// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
46// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
47// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
48#![no_std]
49
50extern crate alloc;
51
52use alloc::vec::Vec;
53use alloc::sync::Arc;
54// `spin` y no `std::sync`: el scratch se toca sólo desde `paint_with` (un hilo),
55// así que el mutex no arbitra nada — sólo aporta la mutabilidad interior `Sync`
56// que el painter exige. El de spin la da sin sistema operativo.
57use spin::Mutex;
58
59use llimphi_compositor::llimphi_layout::taffy::prelude::{auto, percent, Size, Style};
60use llimphi_compositor::peniko::kurbo::{Affine, BezPath, Stroke};
61use llimphi_compositor::peniko::{Color, Fill};
62use llimphi_compositor::View;
63
64/// Paleta + dimensiones del visor de waveform.
65///
66/// El `bg`/`radius` se usan como `fill`/`radius` del nodo contenedor; el
67/// `center`/`stroke`/`fill` se pintan dentro de `paint_with`. Los paddings
68/// definen el margen interior — la onda no toca los bordes redondeados.
69#[derive(Debug, Clone, Copy)]
70pub struct WaveformPalette {
71 /// Fondo del recuadro (se pinta como `fill` del nodo).
72 pub bg: Color,
73 /// Color de la línea central (ground del visor).
74 pub center: Color,
75 /// Color del contorno top/bot del envelope.
76 pub stroke: Color,
77 /// Color del relleno del envelope (típicamente `stroke` con alfa bajo).
78 pub fill: Color,
79 /// Radio de las esquinas del recuadro.
80 pub radius: f64,
81 /// Padding horizontal interior (px) — margen entre el borde y la onda.
82 pub pad_x: f32,
83 /// Padding vertical interior (px).
84 pub pad_y: f32,
85 /// Grosor del stroke del envelope (px).
86 pub stroke_w: f32,
87}
88
89impl Default for WaveformPalette {
90 fn default() -> Self {
91 Self::from_theme(&llimphi_theme::Theme::dark())
92 }
93}
94
95impl WaveformPalette {
96 /// Construye la paleta desde un `Theme` semántico. El relleno del
97 /// envelope se deriva del `accent` con alfa bajo para que se vea como
98 /// "halo" sin pelear con el stroke.
99 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
100 let accent = t.accent;
101 let [r, g, b, _] = accent.components;
102 Self {
103 bg: t.bg_panel_alt,
104 center: t.fg_muted,
105 stroke: accent,
106 // Mismo color que el stroke pero con alfa bajo (≈0.27) para
107 // que el envelope se vea como un halo sin pelear con el contorno.
108 fill: Color::from_rgba8(
109 (r * 255.0) as u8,
110 (g * 255.0) as u8,
111 (b * 255.0) as u8,
112 70,
113 ),
114 radius: 8.0,
115 pad_x: 12.0,
116 pad_y: 8.0,
117 stroke_w: 1.2,
118 }
119 }
120}
121
122/// Compone el visor de waveform.
123///
124/// `source` se invoca **una vez por frame** dentro del `paint_with`: el
125/// caller lo usa para rellenar el buffer con los últimos samples
126/// intercalados y devolver cuántos canales trae. Si devuelve `0` (o el
127/// buffer queda vacío) el widget pinta sólo la línea central. El widget
128/// es stateless: redibuja pasando el mismo closure cada frame y la onda
129/// avanza sola con cada snapshot nuevo.
130///
131/// El widget ocupa el espacio que le dé el padre (`width: auto`, `height:
132/// 100%`); para que crezca dentro de una fila/columna del padre, el
133/// caller lo envuelve con un `flex_grow: 1.0`.
134pub fn waveform_view<Msg, F>(source: F, palette: &WaveformPalette) -> View<Msg>
135where
136 Msg: 'static,
137 F: Fn(&mut Vec<f32>) -> u16 + Send + Sync + 'static,
138{
139 let pal = *palette;
140 // Buffer scratch: se reusa entre frames para no realocar. Es seguro
141 // tenerlo en un `Arc<Mutex>` porque `paint_with` corre en el hilo de
142 // UI (un sólo painter activo por frame).
143 let scratch: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(Vec::new()));
144
145 View::new(Style {
146 size: Size {
147 width: auto(),
148 height: percent(1.0_f32),
149 },
150 flex_grow: 1.0,
151 ..Default::default()
152 })
153 .fill(pal.bg)
154 .radius(pal.radius)
155 // Visualización: sin rol es un nodo vacío. Como Image al menos se anuncia
156 // qué ocupa ese espacio en vez de un hueco mudo.
157 .role(llimphi_compositor::Role::Image)
158 .aria_label("Onda de audio")
159 .paint_with(move |scene, _ts, rect| {
160 if rect.w <= 4.0 || rect.h <= 4.0 {
161 return;
162 }
163 let inner_x = rect.x + pal.pad_x;
164 let inner_y = rect.y + pal.pad_y;
165 let inner_w = (rect.w - 2.0 * pal.pad_x).max(1.0);
166 let inner_h = (rect.h - 2.0 * pal.pad_y).max(1.0);
167 let mid_y = inner_y + inner_h * 0.5;
168
169 // Línea central — siempre presente, hace de "ground" del visor.
170 let mut center = BezPath::new();
171 center.move_to((inner_x as f64, mid_y as f64));
172 center.line_to(((inner_x + inner_w) as f64, mid_y as f64));
173 scene.stroke(
174 &Stroke::new(1.0),
175 Affine::IDENTITY,
176 pal.center,
177 None,
178 ¢er,
179 );
180
181 let mut snap = scratch.lock();
182 let channels = source(&mut snap).max(1) as usize;
183 let total_frames = snap.len() / channels;
184 if total_frames < 2 {
185 return;
186 }
187
188 // Envelope min/max por columna: para cada bucket de frames
189 // guardamos el mínimo y el máximo del mono fold y dibujamos la
190 // forma como un polígono cerrado (relleno tenue + stroke top/bot).
191 // Da mucho más "cuerpo" que la línea pico-sólo.
192 let cols = (inner_w.max(2.0) as usize).min(total_frames);
193 let frames_per_col = total_frames / cols.max(1);
194 if frames_per_col == 0 {
195 return;
196 }
197 let amp = inner_h * 0.5;
198 let denom = (cols as f32 - 1.0).max(1.0);
199
200 let mut top = BezPath::new();
201 let mut bot = BezPath::new();
202 let mut envelope = BezPath::new();
203 // Pasada hacia adelante: top + arranca envelope por el borde
204 // superior. Cacheamos los mínimos para no recorrer el buffer dos
205 // veces.
206 let mut mins = Vec::with_capacity(cols);
207 for col in 0..cols {
208 let f0 = col * frames_per_col;
209 let f1 = ((col + 1) * frames_per_col).min(total_frames);
210 let mut vmin = f32::INFINITY;
211 let mut vmax = f32::NEG_INFINITY;
212 for f in f0..f1 {
213 let mut acc = 0.0_f32;
214 for ch in 0..channels {
215 acc += snap[f * channels + ch];
216 }
217 let v = (acc / channels as f32).clamp(-1.0, 1.0);
218 if v < vmin {
219 vmin = v;
220 }
221 if v > vmax {
222 vmax = v;
223 }
224 }
225 mins.push(vmin);
226 let x = inner_x + (col as f32 / denom) * inner_w;
227 let y_top = mid_y - vmax * amp;
228 let y_bot = mid_y - vmin * amp;
229 if col == 0 {
230 top.move_to((x as f64, y_top as f64));
231 bot.move_to((x as f64, y_bot as f64));
232 envelope.move_to((x as f64, y_top as f64));
233 } else {
234 top.line_to((x as f64, y_top as f64));
235 bot.line_to((x as f64, y_bot as f64));
236 envelope.line_to((x as f64, y_top as f64));
237 }
238 }
239 // Cierre del envelope: vuelve por la línea de mínimos en sentido
240 // inverso (sin recorrer samples otra vez — los mins ya están).
241 for col in (0..cols).rev() {
242 let x = inner_x + (col as f32 / denom) * inner_w;
243 let y_bot = mid_y - mins[col] * amp;
244 envelope.line_to((x as f64, y_bot as f64));
245 }
246 envelope.close_path();
247
248 scene.fill(Fill::NonZero, Affine::IDENTITY, pal.fill, None, &envelope);
249 let stroke = Stroke::new(pal.stroke_w as f64);
250 scene.stroke(&stroke, Affine::IDENTITY, pal.stroke, None, &top);
251 scene.stroke(&stroke, Affine::IDENTITY, pal.stroke, None, &bot);
252 })
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn from_theme_usa_colores_semanticos() {
261 let t = llimphi_theme::Theme::dark();
262 let p = WaveformPalette::from_theme(&t);
263 assert_eq!(p.bg, t.bg_panel_alt);
264 assert_eq!(p.center, t.fg_muted);
265 assert_eq!(p.stroke, t.accent);
266 // fill = accent con alfa bajo (~0.27).
267 let [r0, g0, b0, _] = t.accent.components;
268 let [r1, g1, b1, a1] = p.fill.components;
269 // Componentes RGB iguales módulo el roundtrip f32→u8→f32.
270 assert!((r0 - r1).abs() < 0.01);
271 assert!((g0 - g1).abs() < 0.01);
272 assert!((b0 - b1).abs() < 0.01);
273 // Alfa ≈ 70/255 ≈ 0.274.
274 assert!((a1 - 70.0 / 255.0).abs() < 0.005);
275 }
276
277 #[test]
278 fn construye_sin_panic_sin_senal() {
279 // Closure que reporta 0 canales => sólo pinta la línea central.
280 let pal = WaveformPalette::default();
281 let _ = waveform_view::<(), _>(|_| 0, &pal);
282 }
283
284 #[test]
285 fn construye_con_senal_mono() {
286 let pal = WaveformPalette::default();
287 let _ = waveform_view::<(), _>(
288 |out| {
289 out.clear();
290 for i in 0..1024 {
291 out.push(((i as f32) * 0.01).sin());
292 }
293 1
294 },
295 &pal,
296 );
297 }
298
299 #[test]
300 fn construye_con_senal_estereo() {
301 let pal = WaveformPalette::default();
302 let _ = waveform_view::<(), _>(
303 |out| {
304 out.clear();
305 for i in 0..512 {
306 let t = i as f32 * 0.02;
307 out.push(t.sin());
308 out.push((t * 1.5).sin());
309 }
310 2
311 },
312 &pal,
313 );
314 }
315}