jugar_probar/brick/
audio.rs

1//! AudioBrick: AudioWorklet code generation from brick definitions (PROBAR-SPEC-009-P7)
2//!
3//! Generates AudioWorklet processor JavaScript from brick definitions.
4//! Zero hand-written audio processing code.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use probar::brick::audio::{AudioBrick, AudioParam, RingBufferConfig};
10//!
11//! let audio = AudioBrick::new("whisper-capture")
12//!     .with_ring_buffer(RingBufferConfig {
13//!         size: 144000,  // 3 seconds at 48kHz
14//!         channels: 1,
15//!         use_atomics: true,
16//!     })
17//!     .param(AudioParam::new("gain", 1.0).range(0.0, 2.0));
18//!
19//! // Generate AudioWorklet processor JS
20//! let worklet_js = audio.to_worklet_js();
21//!
22//! // Generate main thread initialization JS
23//! let init_js = audio.to_audio_init_js();
24//! ```
25
26use super::{Brick, BrickAssertion, BrickBudget, BrickVerification};
27use std::time::Duration;
28
29/// Audio parameter configuration
30#[derive(Debug, Clone)]
31pub struct AudioParam {
32    /// Parameter name
33    pub name: String,
34    /// Default value
35    pub default_value: f64,
36    /// Minimum value
37    pub min_value: f64,
38    /// Maximum value
39    pub max_value: f64,
40    /// Automation rate: "a-rate" (per sample) or "k-rate" (per block)
41    pub automation_rate: String,
42}
43
44impl AudioParam {
45    /// Create a new audio parameter
46    #[must_use]
47    pub fn new(name: impl Into<String>, default_value: f64) -> Self {
48        Self {
49            name: name.into(),
50            default_value,
51            min_value: f64::MIN,
52            max_value: f64::MAX,
53            automation_rate: "k-rate".into(),
54        }
55    }
56
57    /// Set the parameter range
58    #[must_use]
59    pub fn range(mut self, min: f64, max: f64) -> Self {
60        self.min_value = min;
61        self.max_value = max;
62        self
63    }
64
65    /// Set to a-rate (per-sample automation)
66    #[must_use]
67    pub fn a_rate(mut self) -> Self {
68        self.automation_rate = "a-rate".into();
69        self
70    }
71
72    /// Set to k-rate (per-block automation)
73    #[must_use]
74    pub fn k_rate(mut self) -> Self {
75        self.automation_rate = "k-rate".into();
76        self
77    }
78
79    /// Generate JavaScript parameter descriptor
80    #[must_use]
81    pub fn to_js_descriptor(&self) -> String {
82        format!(
83            "{{ name: '{}', defaultValue: {}, minValue: {}, maxValue: {}, automationRate: '{}' }}",
84            self.name, self.default_value, self.min_value, self.max_value, self.automation_rate
85        )
86    }
87}
88
89/// Ring buffer configuration for audio data transfer
90#[derive(Debug, Clone)]
91pub struct RingBufferConfig {
92    /// Buffer size in samples
93    pub size: usize,
94    /// Number of audio channels
95    pub channels: usize,
96    /// Use SharedArrayBuffer + Atomics for lock-free transfer
97    pub use_atomics: bool,
98}
99
100impl Default for RingBufferConfig {
101    fn default() -> Self {
102        Self {
103            size: 48000, // 1 second at 48kHz
104            channels: 1,
105            use_atomics: true,
106        }
107    }
108}
109
110impl RingBufferConfig {
111    /// Create a new ring buffer config
112    #[must_use]
113    pub fn new(size: usize) -> Self {
114        Self {
115            size,
116            ..Default::default()
117        }
118    }
119
120    /// Set number of channels
121    #[must_use]
122    pub fn channels(mut self, channels: usize) -> Self {
123        self.channels = channels;
124        self
125    }
126
127    /// Disable atomics (use postMessage instead)
128    #[must_use]
129    pub fn without_atomics(mut self) -> Self {
130        self.use_atomics = false;
131        self
132    }
133}
134
135/// AudioBrick: Generates AudioWorklet processor code
136#[derive(Debug, Clone)]
137pub struct AudioBrick {
138    /// Processor name (used in registerProcessor)
139    name: String,
140    /// Number of inputs
141    inputs: usize,
142    /// Number of outputs
143    outputs: usize,
144    /// Audio parameters
145    params: Vec<AudioParam>,
146    /// Ring buffer configuration (if any)
147    ring_buffer: Option<RingBufferConfig>,
148    /// Sample rate (for calculations)
149    sample_rate: u32,
150}
151
152impl AudioBrick {
153    /// Create a new audio brick
154    #[must_use]
155    pub fn new(name: impl Into<String>) -> Self {
156        Self {
157            name: name.into(),
158            inputs: 1,
159            outputs: 1,
160            params: Vec::new(),
161            ring_buffer: None,
162            sample_rate: 48000,
163        }
164    }
165
166    /// Set number of inputs
167    #[must_use]
168    pub fn inputs(mut self, count: usize) -> Self {
169        self.inputs = count;
170        self
171    }
172
173    /// Set number of outputs
174    #[must_use]
175    pub fn outputs(mut self, count: usize) -> Self {
176        self.outputs = count;
177        self
178    }
179
180    /// Add an audio parameter
181    #[must_use]
182    pub fn param(mut self, param: AudioParam) -> Self {
183        self.params.push(param);
184        self
185    }
186
187    /// Configure ring buffer
188    #[must_use]
189    pub fn with_ring_buffer(mut self, config: RingBufferConfig) -> Self {
190        self.ring_buffer = Some(config);
191        self
192    }
193
194    /// Set expected sample rate
195    #[must_use]
196    pub fn sample_rate(mut self, rate: u32) -> Self {
197        self.sample_rate = rate;
198        self
199    }
200
201    /// Get the processor class name
202    #[must_use]
203    pub fn class_name(&self) -> String {
204        let mut result = String::new();
205        let mut capitalize_next = true;
206
207        for c in self.name.chars() {
208            if c == '-' || c == '_' {
209                capitalize_next = true;
210            } else if capitalize_next {
211                result.push(c.to_ascii_uppercase());
212                capitalize_next = false;
213            } else {
214                result.push(c);
215            }
216        }
217
218        result.push_str("Processor");
219        result
220    }
221
222    /// Generate AudioWorklet processor JavaScript
223    #[must_use]
224    pub fn to_worklet_js(&self) -> String {
225        let mut js = String::new();
226        let class_name = self.class_name();
227
228        // Header
229        js.push_str(&format!("// {} AudioWorklet Processor\n", class_name));
230        js.push_str("// Generated by probar - DO NOT EDIT MANUALLY\n\n");
231
232        // Ring buffer class (if needed)
233        if let Some(ref rb) = self.ring_buffer {
234            if rb.use_atomics {
235                js.push_str(&self.generate_ring_buffer_class(rb));
236                js.push('\n');
237            }
238        }
239
240        // Processor class
241        js.push_str(&format!(
242            "class {} extends AudioWorkletProcessor {{\n",
243            class_name
244        ));
245
246        // Static parameter descriptors
247        if !self.params.is_empty() {
248            js.push_str("    static get parameterDescriptors() {\n");
249            js.push_str("        return [\n");
250            for param in &self.params {
251                js.push_str(&format!("            {},\n", param.to_js_descriptor()));
252            }
253            js.push_str("        ];\n");
254            js.push_str("    }\n\n");
255        }
256
257        // Constructor
258        js.push_str("    constructor() {\n");
259        js.push_str("        super();\n");
260
261        if self.ring_buffer.is_some() {
262            js.push_str("        this.ringBuffer = null;\n");
263            js.push_str("        this.port.onmessage = (e) => {\n");
264            js.push_str("            if (e.data.type === 'init' && e.data.ringBuffer) {\n");
265            js.push_str("                this.ringBuffer = new RingBuffer(e.data.ringBuffer);\n");
266            js.push_str("            }\n");
267            js.push_str("        };\n");
268        }
269
270        js.push_str("    }\n\n");
271
272        // Process method
273        js.push_str("    process(inputs, outputs, parameters) {\n");
274        js.push_str("        const input = inputs[0];\n");
275        js.push_str("        if (!input || !input[0]) return true;\n\n");
276
277        // Copy to output (passthrough)
278        if self.outputs > 0 {
279            js.push_str("        const output = outputs[0];\n");
280            js.push_str("        for (let channel = 0; channel < input.length; channel++) {\n");
281            js.push_str("            if (output[channel]) {\n");
282            js.push_str("                output[channel].set(input[channel]);\n");
283            js.push_str("            }\n");
284            js.push_str("        }\n\n");
285        }
286
287        // Ring buffer write
288        if self.ring_buffer.is_some() {
289            js.push_str("        // Write to ring buffer for worker consumption\n");
290            js.push_str("        if (this.ringBuffer) {\n");
291            js.push_str("            this.ringBuffer.write(input[0]);\n");
292            js.push_str("        }\n\n");
293        }
294
295        js.push_str("        return true; // Keep processor alive\n");
296        js.push_str("    }\n");
297        js.push_str("}\n\n");
298
299        // Register processor
300        js.push_str(&format!(
301            "registerProcessor('{}', {});\n",
302            self.name, class_name
303        ));
304
305        js
306    }
307
308    /// Generate ring buffer class for SharedArrayBuffer
309    fn generate_ring_buffer_class(&self, config: &RingBufferConfig) -> String {
310        format!(
311            r#"// Lock-free ring buffer using SharedArrayBuffer + Atomics
312class RingBuffer {{
313    constructor(sab) {{
314        this.buffer = new Float32Array(sab, 8, {size});
315        this.state = new Int32Array(sab, 0, 2);  // [writeIdx, readIdx]
316    }}
317
318    write(samples) {{
319        const writeIdx = Atomics.load(this.state, 0);
320        const len = samples.length;
321        const bufferLen = this.buffer.length;
322
323        for (let i = 0; i < len; i++) {{
324            this.buffer[(writeIdx + i) % bufferLen] = samples[i];
325        }}
326
327        Atomics.store(this.state, 0, (writeIdx + len) % bufferLen);
328        Atomics.notify(this.state, 0);
329    }}
330
331    read(samples) {{
332        const readIdx = Atomics.load(this.state, 1);
333        const writeIdx = Atomics.load(this.state, 0);
334        const bufferLen = this.buffer.length;
335
336        let available = writeIdx - readIdx;
337        if (available < 0) available += bufferLen;
338
339        const toRead = Math.min(samples.length, available);
340
341        for (let i = 0; i < toRead; i++) {{
342            samples[i] = this.buffer[(readIdx + i) % bufferLen];
343        }}
344
345        Atomics.store(this.state, 1, (readIdx + toRead) % bufferLen);
346        return toRead;
347    }}
348
349    available() {{
350        const readIdx = Atomics.load(this.state, 1);
351        const writeIdx = Atomics.load(this.state, 0);
352        let available = writeIdx - readIdx;
353        if (available < 0) available += {size};
354        return available;
355    }}
356}}
357"#,
358            size = config.size
359        )
360    }
361
362    /// Generate main thread audio initialization JavaScript
363    #[must_use]
364    pub fn to_audio_init_js(&self) -> String {
365        let mut js = String::new();
366
367        js.push_str("// Audio Pipeline Initialization\n");
368        js.push_str("// Generated by probar - DO NOT EDIT MANUALLY\n\n");
369
370        js.push_str("async function initAudio(workletUrl) {\n");
371        js.push_str("    const audioContext = new AudioContext();\n");
372        js.push_str("    await audioContext.audioWorklet.addModule(workletUrl);\n\n");
373
374        // Create ring buffer if needed
375        if let Some(ref rb) = self.ring_buffer {
376            let buffer_bytes = rb.size * 4 + 8; // Float32 + state
377            js.push_str(&format!(
378                "    // Ring buffer: {} samples ({} bytes)\n",
379                rb.size, buffer_bytes
380            ));
381            js.push_str(&format!(
382                "    const ringBufferSab = new SharedArrayBuffer({});\n\n",
383                buffer_bytes
384            ));
385        }
386
387        // Create worklet node
388        js.push_str(&format!(
389            "    const workletNode = new AudioWorkletNode(audioContext, '{}');\n",
390            self.name
391        ));
392
393        // Send ring buffer to worklet
394        if self.ring_buffer.is_some() {
395            js.push_str(
396                "    workletNode.port.postMessage({ type: 'init', ringBuffer: ringBufferSab });\n",
397            );
398        }
399
400        js.push_str("\n    return { audioContext, workletNode");
401        if self.ring_buffer.is_some() {
402            js.push_str(", ringBufferSab");
403        }
404        js.push_str(" };\n");
405        js.push_str("}\n");
406
407        js
408    }
409
410    /// Generate Rust bindings for ring buffer access
411    #[must_use]
412    pub fn to_rust_bindings(&self) -> String {
413        let mut rust = String::new();
414
415        rust.push_str(&format!("//! {} Audio Bindings\n", self.class_name()));
416        rust.push_str("//! Generated by probar - DO NOT EDIT MANUALLY\n\n");
417
418        if let Some(ref rb) = self.ring_buffer {
419            rust.push_str("use std::sync::atomic::{AtomicI32, Ordering};\n\n");
420
421            rust.push_str(&format!(
422                "pub const RING_BUFFER_SIZE: usize = {};\n",
423                rb.size
424            ));
425            rust.push_str(&format!(
426                "pub const RING_BUFFER_CHANNELS: usize = {};\n\n",
427                rb.channels
428            ));
429
430            rust.push_str("/// Lock-free ring buffer for audio data transfer\n");
431            rust.push_str("pub struct AudioRingBuffer {\n");
432            rust.push_str("    buffer: js_sys::Float32Array,\n");
433            rust.push_str("    state: js_sys::Int32Array,\n");
434            rust.push_str("}\n\n");
435
436            rust.push_str("impl AudioRingBuffer {\n");
437            rust.push_str("    /// Create from SharedArrayBuffer\n");
438            rust.push_str("    pub fn new(sab: js_sys::SharedArrayBuffer) -> Self {\n");
439            rust.push_str(&format!(
440                "        let buffer = js_sys::Float32Array::new_with_byte_offset_and_length(&sab, 8, {});\n",
441                rb.size
442            ));
443            rust.push_str("        let state = js_sys::Int32Array::new_with_byte_offset_and_length(&sab, 0, 2);\n");
444            rust.push_str("        Self { buffer, state }\n");
445            rust.push_str("    }\n\n");
446
447            rust.push_str("    /// Read available samples\n");
448            rust.push_str("    pub fn read(&self, output: &mut [f32]) -> usize {\n");
449            rust.push_str("        // Implementation uses Atomics for thread-safe access\n");
450            rust.push_str("        let read_idx = self.state.get_index(1) as usize;\n");
451            rust.push_str("        let write_idx = self.state.get_index(0) as usize;\n");
452            rust.push_str(&format!("        let buffer_len = {};\n", rb.size));
453            rust.push_str("        \n");
454            rust.push_str("        let mut available = write_idx as i32 - read_idx as i32;\n");
455            rust.push_str("        if available < 0 { available += buffer_len as i32; }\n");
456            rust.push_str("        \n");
457            rust.push_str("        let to_read = output.len().min(available as usize);\n");
458            rust.push_str("        for i in 0..to_read {\n");
459            rust.push_str("            output[i] = self.buffer.get_index(((read_idx + i) % buffer_len) as u32);\n");
460            rust.push_str("        }\n");
461            rust.push_str("        \n");
462            rust.push_str(
463                "        self.state.set_index(1, ((read_idx + to_read) % buffer_len) as i32);\n",
464            );
465            rust.push_str("        to_read\n");
466            rust.push_str("    }\n\n");
467
468            rust.push_str("    /// Get number of available samples\n");
469            rust.push_str("    pub fn available(&self) -> usize {\n");
470            rust.push_str("        let read_idx = self.state.get_index(1) as i32;\n");
471            rust.push_str("        let write_idx = self.state.get_index(0) as i32;\n");
472            rust.push_str("        let mut available = write_idx - read_idx;\n");
473            rust.push_str(&format!(
474                "        if available < 0 {{ available += {}; }}\n",
475                rb.size
476            ));
477            rust.push_str("        available as usize\n");
478            rust.push_str("    }\n");
479            rust.push_str("}\n");
480        }
481
482        rust
483    }
484}
485
486impl Brick for AudioBrick {
487    fn brick_name(&self) -> &'static str {
488        "AudioBrick"
489    }
490
491    fn assertions(&self) -> &[BrickAssertion] {
492        &[]
493    }
494
495    fn budget(&self) -> BrickBudget {
496        // Audio processing has strict real-time requirements
497        // 128 samples at 48kHz = 2.67ms per block
498        BrickBudget::uniform(3)
499    }
500
501    fn verify(&self) -> BrickVerification {
502        let mut passed = Vec::new();
503        let mut failed = Vec::new();
504
505        // Verify ring buffer size is reasonable
506        if let Some(ref rb) = self.ring_buffer {
507            if rb.size >= 128 && rb.size <= 48000 * 10 {
508                passed.push(BrickAssertion::Custom {
509                    name: "ring_buffer_size_valid".into(),
510                    validator_id: 20,
511                });
512            } else {
513                failed.push((
514                    BrickAssertion::Custom {
515                        name: "ring_buffer_size_valid".into(),
516                        validator_id: 20,
517                    },
518                    format!("Ring buffer size {} out of range (128-480000)", rb.size),
519                ));
520            }
521        }
522
523        // Verify parameter ranges
524        for param in &self.params {
525            if param.min_value < param.max_value {
526                passed.push(BrickAssertion::Custom {
527                    name: format!("param_{}_range_valid", param.name),
528                    validator_id: 21,
529                });
530            } else {
531                failed.push((
532                    BrickAssertion::Custom {
533                        name: format!("param_{}_range_valid", param.name),
534                        validator_id: 21,
535                    },
536                    "min >= max".into(),
537                ));
538            }
539        }
540
541        BrickVerification {
542            passed,
543            failed,
544            verification_time: Duration::from_micros(50),
545        }
546    }
547
548    fn to_html(&self) -> String {
549        String::new()
550    }
551
552    fn to_css(&self) -> String {
553        String::new()
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn test_audio_brick_basic() {
563        let audio = AudioBrick::new("whisper-capture");
564        assert_eq!(audio.name, "whisper-capture");
565        assert_eq!(audio.inputs, 1);
566        assert_eq!(audio.outputs, 1);
567    }
568
569    #[test]
570    fn test_audio_brick_class_name() {
571        let audio = AudioBrick::new("whisper-capture");
572        assert_eq!(audio.class_name(), "WhisperCaptureProcessor");
573
574        let audio2 = AudioBrick::new("my_processor");
575        assert_eq!(audio2.class_name(), "MyProcessorProcessor");
576    }
577
578    #[test]
579    fn test_audio_param() {
580        let param = AudioParam::new("gain", 1.0).range(0.0, 2.0).a_rate();
581
582        assert_eq!(param.name, "gain");
583        assert_eq!(param.default_value, 1.0);
584        assert_eq!(param.min_value, 0.0);
585        assert_eq!(param.max_value, 2.0);
586        assert_eq!(param.automation_rate, "a-rate");
587    }
588
589    #[test]
590    fn test_audio_param_js_descriptor() {
591        let param = AudioParam::new("volume", 0.5).range(0.0, 1.0);
592        let js = param.to_js_descriptor();
593
594        assert!(js.contains("name: 'volume'"));
595        assert!(js.contains("defaultValue: 0.5"));
596        assert!(js.contains("minValue: 0"));
597        assert!(js.contains("maxValue: 1"));
598    }
599
600    #[test]
601    fn test_ring_buffer_config() {
602        let config = RingBufferConfig::new(48000).channels(2);
603
604        assert_eq!(config.size, 48000);
605        assert_eq!(config.channels, 2);
606        assert!(config.use_atomics);
607    }
608
609    #[test]
610    fn test_worklet_js_generation() {
611        let audio = AudioBrick::new("test-processor")
612            .param(AudioParam::new("gain", 1.0))
613            .with_ring_buffer(RingBufferConfig::new(24000));
614
615        let js = audio.to_worklet_js();
616
617        assert!(js.contains("Generated by probar"));
618        assert!(js.contains("class TestProcessorProcessor"));
619        assert!(js.contains("extends AudioWorkletProcessor"));
620        assert!(js.contains("parameterDescriptors"));
621        assert!(js.contains("process(inputs, outputs, parameters)"));
622        assert!(js.contains("registerProcessor('test-processor'"));
623        assert!(js.contains("RingBuffer"));
624    }
625
626    #[test]
627    fn test_audio_init_js_generation() {
628        let audio = AudioBrick::new("capture").with_ring_buffer(RingBufferConfig::new(48000));
629
630        let js = audio.to_audio_init_js();
631
632        assert!(js.contains("AudioContext"));
633        assert!(js.contains("audioWorklet.addModule"));
634        assert!(js.contains("SharedArrayBuffer"));
635        assert!(js.contains("AudioWorkletNode"));
636    }
637
638    #[test]
639    fn test_verification_valid() {
640        let audio = AudioBrick::new("test")
641            .param(AudioParam::new("gain", 1.0).range(0.0, 2.0))
642            .with_ring_buffer(RingBufferConfig::new(24000));
643
644        let result = audio.verify();
645        assert!(result.is_valid());
646    }
647
648    #[test]
649    fn test_verification_invalid_param() {
650        let audio = AudioBrick::new("test").param(AudioParam::new("bad", 1.0).range(2.0, 1.0)); // min > max
651
652        let result = audio.verify();
653        assert!(!result.is_valid());
654    }
655}