Skip to main content

awsm_audio_player/
lib.rs

1//! awsm-audio-player — the WebAudio playback engine for the awsm-audio editor.
2//!
3//! [`Player`] owns the live `AudioContext` and a fixed master chain
4//! (`master gain → analyser → destination`). [`Player::play`] instantiates an
5//! authored [`Graph`] onto the context (see the `build` module) and routes it into the
6//! master bus; [`Player::stop`] tears the instance down. The analyser exposes
7//! time-domain samples for the editor's waveform view.
8
9pub mod bounce;
10mod build;
11pub mod document;
12mod noise;
13pub mod worklet;
14
15use std::collections::HashMap;
16
17use anyhow::Result;
18use wasm_bindgen::JsCast;
19use wasm_bindgen::JsValue;
20use web_sys::{
21    AnalyserNode, AudioBuffer, AudioBufferSourceNode, AudioContext, AudioNode,
22    AudioScheduledSourceNode, GainNode,
23};
24
25use awsm_audio_schema::{AssetId, Graph, Listener, NodeId};
26
27/// Version string baked from the crate manifest (handy link-check symbol).
28pub fn version() -> &'static str {
29    env!("CARGO_PKG_VERSION")
30}
31
32/// Owns the `AudioContext` and the persistent master chain, plus whatever graph
33/// instance is currently playing.
34pub struct Player {
35    ctx: AudioContext,
36    master: GainNode,
37    analyser: AnalyserNode,
38    /// Nodes of the currently-playing graph, kept alive until `stop`.
39    inner: Vec<AudioNode>,
40    /// Source nodes (oscillators etc.) that were `start()`ed.
41    sources: Vec<AudioScheduledSourceNode>,
42    /// Per-node automatable params (by WebAudio name) of the currently-playing
43    /// graph, so a param can be nudged live (MIDI CC) without a full rebuild.
44    params: Vec<(NodeId, Vec<(&'static str, web_sys::AudioParam)>)>,
45    /// Voices materialized for a scheduled song (one per note), kept alive for
46    /// the whole song; cleared on `stop`.
47    song_voices: Vec<Voice>,
48    /// When an arrangement is playing: every node's live `AudioNode` by id, so
49    /// the trigger scheduler can spawn voices into an instrument-ref's voice-bus
50    /// gain. Empty otherwise; cleared on `stop`.
51    bus_nodes: Vec<(NodeId, AudioNode)>,
52    /// Decoded audio buffers, keyed by the schema asset id a buffer source
53    /// references. Survives play/stop so a clip only decodes once.
54    buffers: HashMap<AssetId, AudioBuffer>,
55    /// Compiled WASM DSP modules, keyed by the asset id an AudioWorklet node
56    /// references. Survives play/stop so a module only compiles once.
57    modules: HashMap<AssetId, js_sys::WebAssembly::Module>,
58    /// Whether the generic `awsm-wasm` worklet shim has finished `addModule`
59    /// (worklet nodes can't be constructed until it has).
60    worklet_ready: bool,
61    /// The captured microphone stream, if the user granted access — fed to any
62    /// MediaStream source node.
63    mic: Option<web_sys::MediaStream>,
64    /// The spatial listener applied each play (position/orientation).
65    listener: Option<Listener>,
66}
67
68/// Upper bound on simultaneously-scheduled song notes — a backstop against
69/// pathological MIDI files. Excess notes are dropped (the caller logs it).
70const MAX_SONG_VOICES: usize = 4096;
71
72/// One sound's worth of triggered notes within an arrangement: the instrument
73/// to instantiate, the arrangement node whose voice-bus its voices feed, and the
74/// notes (already resolved to seconds + transpose + gain).
75pub struct TriggerPart {
76    /// The arrangement node id (an instrument-ref) whose voice-bus gain receives
77    /// this part's voices. Its audio then flows on through the arrangement graph.
78    pub target: NodeId,
79    /// The flattened instrument sample, instantiated once per note.
80    pub instrument: Graph,
81    pub notes: Vec<SongVoiceSpec>,
82}
83
84/// One control lane to automate: a target node's AudioParam plus its breakpoints
85/// (already resolved to seconds-from-start + absolute value + the curve reaching
86/// each point from the previous one).
87pub struct ControlLanePart {
88    pub target: NodeId,
89    pub param: String,
90    pub points: Vec<(f64, f32, awsm_audio_schema::Curve)>,
91}
92
93/// One bounced audio clip to schedule on the arrangement timeline. Times are in
94/// seconds; `start` is relative to the playback origin (the controller applies the
95/// scrub seek), `offset` is into the buffer, `length` is how long to play.
96pub struct AudioClipPart {
97    pub buffer: AssetId,
98    pub start: f64,
99    pub offset: f64,
100    pub length: f64,
101    pub gain: f32,
102    /// Gain automation points relative to this clip part's audible start:
103    /// `(seconds_from_part_start, absolute_linear_gain)`.
104    pub gain_curve: Vec<(f64, f32)>,
105    pub looping: bool,
106    /// Playback rate (1.0 = normal). The clip occupies `length` seconds on the
107    /// timeline but consumes `length * speed` seconds of buffer.
108    pub speed: f64,
109}
110
111/// One scheduled note within a [`TriggerPart`].
112pub struct SongVoiceSpec {
113    /// Onset, in seconds from the song's (seek-adjusted) start.
114    pub start: f64,
115    /// Note-off, in seconds (release tail extends past this).
116    pub end: f64,
117    /// Semitone transpose of the instrument for this note (60 = unison → 0).
118    pub semitones: i32,
119    /// Linear amplitude (velocity × part gain), 0..=1.
120    pub velocity: f32,
121}
122
123/// One sounding polyphonic voice: an independent instance of the patch routed
124/// through its own `gain` (velocity + release envelope) into the master bus.
125struct Voice {
126    gain: GainNode,
127    /// All inner nodes, kept alive while the voice sounds.
128    nodes: Vec<AudioNode>,
129    sources: Vec<AudioScheduledSourceNode>,
130    /// When the sources are scheduled to stop.
131    stop_at: f64,
132}
133
134impl Voice {
135    /// Stop sources now and disconnect everything from the graph.
136    fn teardown(self) {
137        for s in &self.sources {
138            let _ = s.stop();
139        }
140        for n in &self.nodes {
141            let _ = n.disconnect();
142        }
143        let _ = self.gain.disconnect();
144    }
145}
146
147/// Spawn a voice per note of each [`TriggerPart`] into its bus node, on any
148/// context (live or offline). `t0` is the absolute start time; voices are pushed
149/// to `out` (kept alive by the caller). Returns the latest stop time. Shared by
150/// the live scheduler and the offline bounce renderer.
151#[allow(clippy::too_many_arguments)]
152fn spawn_voices(
153    ctx: &web_sys::BaseAudioContext,
154    bus_nodes: &[(NodeId, AudioNode)],
155    buffers: &HashMap<AssetId, AudioBuffer>,
156    modules: &HashMap<AssetId, js_sys::WebAssembly::Module>,
157    worklet_ready: bool,
158    mic: Option<&web_sys::MediaStream>,
159    parts: &[TriggerPart],
160    t0: f64,
161    out: &mut Vec<Voice>,
162    room: usize,
163) -> Result<f64> {
164    const ATTACK: f64 = 0.004;
165    const RELEASE: f64 = 0.08;
166    let mut end_time = t0;
167    'outer: for part in parts {
168        let Some(target) = bus_nodes
169            .iter()
170            .find(|(id, _)| *id == part.target)
171            .map(|(_, n)| n.clone())
172        else {
173            continue;
174        };
175        for note in &part.notes {
176            if out.len() >= room {
177                break 'outer;
178            }
179            let on = t0 + note.start;
180            let off = t0 + note.end.max(note.start);
181            let gain = ctx
182                .create_gain()
183                .map_err(|e| anyhow::anyhow!("song gain: {e:?}"))?;
184            let g = gain.gain();
185            let _ = g.set_value_at_time(0.0, on);
186            let _ = g.set_target_at_time(note.velocity.clamp(0.0, 1.0), on, ATTACK);
187            let _ = g.set_target_at_time(0.0, off, RELEASE / 3.0);
188            gain.connect_with_audio_node(&target)
189                .map_err(|e| anyhow::anyhow!("song voice→bus: {e:?}"))?;
190            let graph = part.instrument.transposed(note.semitones);
191            let built = build::build_graph(
192                ctx,
193                &graph,
194                &gain,
195                buffers,
196                modules,
197                mic,
198                worklet_ready,
199                false,
200                on,
201            )?;
202            let stop_at = off + RELEASE * 3.0;
203            end_time = end_time.max(stop_at);
204            for s in &built.sources {
205                let _ = s.start_with_when(on);
206                let _ = s.stop_with_when(stop_at);
207            }
208            out.push(Voice {
209                gain,
210                nodes: built.inner,
211                sources: built.sources,
212                stop_at,
213            });
214        }
215    }
216    Ok(end_time)
217}
218
219/// Apply control-lane automation onto already-built params, on any context.
220/// `at` is the absolute start time. Shared by the live scheduler and bounce.
221fn apply_control(
222    params: &[(NodeId, Vec<(&'static str, web_sys::AudioParam)>)],
223    parts: &[ControlLanePart],
224    at: f64,
225) {
226    use awsm_audio_schema::Curve;
227    const EPS: f32 = 1e-4;
228    for part in parts {
229        let Some(param) = params
230            .iter()
231            .find(|(id, _)| *id == part.target)
232            .and_then(|(_, ps)| {
233                ps.iter()
234                    .find(|(n, _)| *n == part.param)
235                    .map(|(_, p)| p.clone())
236            })
237        else {
238            continue;
239        };
240        let mut pts = part.points.clone();
241        pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
242        let mut prev: Option<(f64, f32)> = None;
243        for (i, (secs, value, curve)) in pts.iter().enumerate() {
244            let t = at + secs.max(0.0);
245            let v = *value;
246            if i == 0 {
247                let _ = param.set_value_at_time(v, t);
248                prev = Some((t, v));
249                continue;
250            }
251            match curve {
252                Curve::Step => {
253                    let _ = param.set_value_at_time(v, t);
254                }
255                Curve::Linear => {
256                    let _ = param.linear_ramp_to_value_at_time(v, t);
257                }
258                Curve::Exponential => {
259                    if let Some((pt, pv)) = prev {
260                        if pv.abs() < EPS {
261                            let _ = param.set_value_at_time(EPS, pt);
262                        }
263                    }
264                    let target = if v.abs() < EPS { EPS } else { v };
265                    let _ = param.exponential_ramp_to_value_at_time(target, t);
266                }
267                Curve::Smooth => {
268                    if let Some((pt, pv)) = prev {
269                        const N: usize = 24;
270                        let mut curve_vals = vec![0.0f32; N];
271                        for (k, slot) in curve_vals.iter_mut().enumerate() {
272                            let x = k as f32 / (N - 1) as f32;
273                            let s = x * x * (3.0 - 2.0 * x);
274                            *slot = pv + (v - pv) * s;
275                        }
276                        let dur = (t - pt).max(0.001);
277                        let _ = param.set_value_curve_at_time(&mut curve_vals, pt, dur);
278                    } else {
279                        let _ = param.linear_ramp_to_value_at_time(v, t);
280                    }
281                }
282            }
283            prev = Some((t, v));
284        }
285    }
286}
287
288fn apply_clip_gain_curve(
289    param: &web_sys::AudioParam,
290    fallback: f32,
291    points: &[(f64, f32)],
292    at: f64,
293) {
294    if points.is_empty() {
295        param.set_value(fallback);
296        return;
297    }
298    let mut pts = points.to_vec();
299    pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
300    let (t0, v0) = pts[0];
301    let _ = param.set_value_at_time(v0, at + t0.max(0.0));
302    for (secs, gain) in pts.into_iter().skip(1) {
303        let _ = param.linear_ramp_to_value_at_time(gain, at + secs.max(0.0));
304    }
305}
306
307impl Player {
308    /// Create a player with `master → analyser → destination` wired up. The
309    /// context starts suspended until [`play`](Self::play) resumes it (a click
310    /// satisfies the browser's gesture requirement).
311    pub fn new() -> Result<Self> {
312        let ctx = AudioContext::new().map_err(|e| anyhow::anyhow!("AudioContext: {e:?}"))?;
313        let master = ctx
314            .create_gain()
315            .map_err(|e| anyhow::anyhow!("master gain: {e:?}"))?;
316        let analyser = ctx
317            .create_analyser()
318            .map_err(|e| anyhow::anyhow!("analyser: {e:?}"))?;
319        analyser.set_fft_size(2048);
320        master
321            .connect_with_audio_node(&analyser)
322            .map_err(|e| anyhow::anyhow!("master→analyser: {e:?}"))?;
323        analyser
324            .connect_with_audio_node(&ctx.destination())
325            .map_err(|e| anyhow::anyhow!("analyser→destination: {e:?}"))?;
326        Ok(Self {
327            ctx,
328            master,
329            analyser,
330            inner: Vec::new(),
331            sources: Vec::new(),
332            params: Vec::new(),
333            song_voices: Vec::new(),
334            bus_nodes: Vec::new(),
335            buffers: HashMap::new(),
336            modules: HashMap::new(),
337            worklet_ready: false,
338            mic: None,
339            listener: None,
340        })
341    }
342
343    /// Begin loading the generic WASM worklet shim into this context (idempotent
344    /// once ready). Returns the `addModule` promise; await it, then call
345    /// [`mark_worklet_ready`](Self::mark_worklet_ready). Done as a Blob URL so no
346    /// static file needs serving.
347    pub fn add_worklet_shim(&self) -> Result<js_sys::Promise> {
348        let parts = js_sys::Array::new();
349        parts.push(&JsValue::from_str(&worklet::shim_source()));
350        let bag = web_sys::BlobPropertyBag::new();
351        bag.set_type("text/javascript");
352        let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &bag)
353            .map_err(|e| anyhow::anyhow!("blob: {e:?}"))?;
354        let url = web_sys::Url::create_object_url_with_blob(&blob)
355            .map_err(|e| anyhow::anyhow!("blob url: {e:?}"))?;
356        let wl = self
357            .ctx
358            .audio_worklet()
359            .map_err(|e| anyhow::anyhow!("audioWorklet: {e:?}"))?;
360        wl.add_module(&url)
361            .map_err(|e| anyhow::anyhow!("addModule: {e:?}"))
362    }
363
364    /// Mark the worklet shim ready (after its `addModule` promise resolved).
365    pub fn mark_worklet_ready(&mut self) {
366        self.worklet_ready = true;
367    }
368
369    /// Whether the worklet shim is loaded.
370    pub fn worklet_ready(&self) -> bool {
371        self.worklet_ready
372    }
373
374    /// Compile raw `.wasm` bytes into a `WebAssembly.Module`. Returns the
375    /// `WebAssembly.compile` promise (resolves to the module).
376    pub fn compile_module(bytes: &js_sys::Uint8Array) -> js_sys::Promise {
377        js_sys::WebAssembly::compile(bytes.as_ref())
378    }
379
380    /// Register a compiled module under `id` (referenced by an AudioWorklet node).
381    pub fn store_module(&mut self, id: AssetId, module: js_sys::WebAssembly::Module) {
382        self.modules.insert(id, module);
383    }
384
385    /// Whether a compiled module is registered for `id`.
386    pub fn has_module(&self, id: &AssetId) -> bool {
387        self.modules.contains_key(id)
388    }
389
390    /// Decode encoded audio (mp3/wav/flac/…) into an `AudioBuffer` via the
391    /// context. Returns the `decodeAudioData` promise for the caller to await.
392    pub fn decode(&self, data: &js_sys::ArrayBuffer) -> Result<js_sys::Promise> {
393        self.ctx
394            .decode_audio_data(data)
395            .map_err(|e| anyhow::anyhow!("decodeAudioData: {e:?}"))
396    }
397
398    /// Register a decoded buffer under `id` (referenced by a buffer-source node).
399    pub fn store_buffer(&mut self, id: AssetId, buffer: AudioBuffer) {
400        self.buffers.insert(id, buffer);
401    }
402
403    /// Build an `AudioBuffer` from raw PCM (one `Vec<f32>` per channel) and
404    /// register it under `id`.
405    pub fn store_pcm(
406        &mut self,
407        id: AssetId,
408        sample_rate: f32,
409        channels: &[Vec<f32>],
410    ) -> Result<()> {
411        let ch = channels.len().max(1) as u32;
412        let len = channels.iter().map(Vec::len).max().unwrap_or(1).max(1) as u32;
413        let buffer = self
414            .ctx
415            .create_buffer(ch, len, sample_rate)
416            .map_err(|e| anyhow::anyhow!("create_buffer: {e:?}"))?;
417        for (i, data) in channels.iter().enumerate() {
418            buffer
419                .copy_to_channel(data, i as i32)
420                .map_err(|e| anyhow::anyhow!("copy_to_channel: {e:?}"))?;
421        }
422        self.buffers.insert(id, buffer);
423        Ok(())
424    }
425
426    /// Begin a `getUserMedia({audio:true})` request; returns the promise
427    /// (resolves to a `MediaStream`). The caller awaits + [`set_mic`](Self::set_mic).
428    pub fn request_mic(&self) -> Result<js_sys::Promise> {
429        let nav = web_sys::window()
430            .ok_or_else(|| anyhow::anyhow!("no window"))?
431            .navigator();
432        let devices = nav
433            .media_devices()
434            .map_err(|e| anyhow::anyhow!("mediaDevices: {e:?}"))?;
435        let constraints = web_sys::MediaStreamConstraints::new();
436        constraints.set_audio(&JsValue::TRUE);
437        devices
438            .get_user_media_with_constraints(&constraints)
439            .map_err(|e| anyhow::anyhow!("getUserMedia: {e:?}"))
440    }
441
442    /// Store the captured microphone stream (fed to MediaStream source nodes).
443    pub fn set_mic(&mut self, stream: web_sys::MediaStream) {
444        self.mic = Some(stream);
445    }
446
447    /// Set the spatial listener applied on each play/render.
448    pub fn set_listener(&mut self, listener: Option<Listener>) {
449        self.listener = listener;
450    }
451
452    /// Set the persistent master-bus gain (0..1+), live. Used for MIDI velocity
453    /// sensitivity — it survives `play`/`stop` since the master chain is fixed.
454    pub fn set_master_gain(&self, gain: f32) {
455        self.master.gain().set_value(gain);
456    }
457
458    /// Tear down any running instance, build `graph`, route its terminals to the
459    /// master bus, start every source, and resume the context.
460    pub fn play(&mut self, graph: &Graph, looping: bool) -> Result<()> {
461        self.stop();
462        // Note-on time: automation in the graph is scheduled relative to this.
463        let t0 = self.ctx.current_time();
464        let built = build::build_graph(
465            &self.ctx,
466            graph,
467            &self.master,
468            &self.buffers,
469            &self.modules,
470            self.mic.as_ref(),
471            self.worklet_ready,
472            looping,
473            t0,
474        )?;
475        self.inner = built.inner;
476        self.sources = built.sources;
477        self.params = built.params;
478        // Keep the id→node map so per-node Analyser scopes can read their data.
479        self.bus_nodes = built.nodes;
480        if let Some(l) = &self.listener {
481            build::apply_listener(&self.ctx, l, t0);
482        }
483        for s in &self.sources {
484            // A source can only be started once; these are freshly built.
485            let _ = s.start();
486        }
487        let _ = self.ctx.resume();
488        Ok(())
489    }
490
491    /// Resume the audio context — call it from a user-gesture handler (click /
492    /// keypress) to satisfy the browser's autoplay policy before/at the first
493    /// [`play`](Self::play). Idempotent; harmless once running.
494    pub fn resume(&self) {
495        let _ = self.ctx.resume();
496    }
497
498    /// Time-domain samples (0..255, 128 = silence) of the Analyser node `id` in
499    /// the live graph — for a per-node oscilloscope. Empty if `id` isn't a live
500    /// Analyser.
501    pub fn scope(&self, id: NodeId) -> Vec<u8> {
502        let Some((_, node)) = self.bus_nodes.iter().find(|(n, _)| *n == id) else {
503            return Vec::new();
504        };
505        if let Some(an) = node.dyn_ref::<AnalyserNode>() {
506            let mut buf = vec![0u8; an.fft_size() as usize];
507            an.get_byte_time_domain_data(&mut buf);
508            buf
509        } else {
510            Vec::new()
511        }
512    }
513
514    /// Stop and disconnect the current instance (the master chain stays intact),
515    /// plus every scheduled song voice.
516    pub fn stop(&mut self) {
517        for s in self.sources.drain(..) {
518            let _ = s.stop();
519        }
520        for n in self.inner.drain(..) {
521            let _ = n.disconnect();
522        }
523        self.params.clear();
524        self.bus_nodes.clear();
525        for v in self.song_voices.drain(..) {
526            v.teardown();
527        }
528    }
529
530    /// The audio context's current time (seconds) — the clock the song scheduler
531    /// and loop re-arm measure against.
532    pub fn current_time(&self) -> f64 {
533        self.ctx.current_time()
534    }
535
536    /// The context sample rate (Hz).
537    pub fn sample_rate(&self) -> u32 {
538        self.ctx.sample_rate() as u32
539    }
540
541    /// A clone of the decoded/rendered buffer registry (AudioBuffers are
542    /// context-independent), for handing to the offline arrangement renderer
543    /// ([`bounce::render_clips`]).
544    pub fn clip_buffers(&self) -> std::collections::HashMap<AssetId, AudioBuffer> {
545        self.buffers.clone()
546    }
547
548    /// Whether a decoded/rendered buffer is registered under `id`.
549    pub fn has_buffer(&self, id: AssetId) -> bool {
550        self.buffers.contains_key(&id)
551    }
552
553    /// Assemble an offline [`bounce`] job from the live state. Clones the buffer
554    /// and module registries so the returned future owns everything (no borrow of
555    /// the player across `await`). `await crate::bounce::render(job)` to get PCM.
556    pub fn bounce_job(
557        &self,
558        graph: Graph,
559        parts: Vec<TriggerPart>,
560        control: Vec<ControlLanePart>,
561        duration_secs: f64,
562        loop_secs: Option<f64>,
563    ) -> bounce::BounceJob {
564        bounce::BounceJob {
565            graph,
566            parts,
567            control,
568            duration_secs,
569            loop_secs,
570            sample_rate: self.ctx.sample_rate(),
571            buffers: self.buffers.clone(),
572            modules: self.modules.clone(),
573            shim_source: worklet::shim_source(),
574        }
575    }
576
577    /// Begin an audio-clip arrangement: tear down any prior instance and resume.
578    pub fn arrange_audio_begin(&mut self) {
579        self.stop();
580        let _ = self.ctx.resume();
581    }
582
583    /// Schedule one pass of audio clips at absolute time `at` (additive — the
584    /// transport-loop re-arm calls this again for the next pass). Reclaims
585    /// finished sources first. Returns the latest end time.
586    pub fn schedule_audio_clips(&mut self, clips: &[AudioClipPart], at: f64) -> Result<f64> {
587        let now = self.ctx.current_time();
588        let mut i = 0;
589        while i < self.song_voices.len() {
590            if self.song_voices[i].stop_at <= now {
591                self.song_voices.swap_remove(i).teardown();
592            } else {
593                i += 1;
594            }
595        }
596        let mut end = at;
597        for c in clips {
598            let Some(buf) = self.buffers.get(&c.buffer).cloned() else {
599                continue;
600            };
601            let when = at + c.start.max(0.0);
602            let dur = c.length.max(0.0);
603            let off = c.offset.max(0.0);
604            let speed = if c.speed > 0.0 { c.speed } else { 1.0 };
605            if dur <= 0.0 {
606                continue;
607            }
608            let buf_dur = buf.duration();
609            // Buffer seconds consumed = timeline length × speed.
610            let span = dur * speed;
611            let stretched = c.looping && span > (buf_dur - off) + 1e-3;
612
613            let (src, g) = self.new_clip_source(&buf)?;
614            apply_clip_gain_curve(&g.gain(), c.gain, &c.gain_curve, when);
615            if (speed - 1.0).abs() > 1e-6 {
616                src.playback_rate().set_value(speed as f32);
617            }
618            let sched: AudioScheduledSourceNode = src.clone().unchecked_into();
619            if stretched {
620                // Native loop. The bounce is rendered as an exact loop region
621                // (with its wrap-around tail folded back onto the start), so the
622                // seam is seamless without any crossfade. Playback rate scales it.
623                src.set_loop(true);
624                src.set_loop_start(off);
625                src.set_loop_end(buf_dur);
626                let _ = src.start_with_when_and_grain_offset(when, off);
627                let _ = sched.stop_with_when(when + dur);
628            } else {
629                // grain_duration is in buffer seconds (`span`); at `speed` it plays
630                // for `dur` real seconds.
631                let _ = src.start_with_when_and_grain_offset_and_grain_duration(when, off, span);
632            }
633            let stop_at = when + dur + 0.05;
634            end = end.max(stop_at);
635            self.song_voices.push(Voice {
636                gain: g,
637                nodes: Vec::new(),
638                sources: vec![sched],
639                stop_at,
640            });
641        }
642        let _ = self.ctx.resume();
643        Ok(end)
644    }
645
646    /// Create a clip buffer source wired `source → gain → master` (gain left at
647    /// its default 1.0 for the caller to set or automate).
648    fn new_clip_source(&self, buf: &AudioBuffer) -> Result<(AudioBufferSourceNode, GainNode)> {
649        let src = self
650            .ctx
651            .create_buffer_source()
652            .map_err(|e| anyhow::anyhow!("buffer source: {e:?}"))?;
653        src.set_buffer(Some(buf));
654        let g = self
655            .ctx
656            .create_gain()
657            .map_err(|e| anyhow::anyhow!("clip gain: {e:?}"))?;
658        src.connect_with_audio_node(&g)
659            .map_err(|e| anyhow::anyhow!("clip src→gain: {e:?}"))?;
660        g.connect_with_audio_node(&self.master)
661            .map_err(|e| anyhow::anyhow!("clip gain→master: {e:?}"))?;
662        Ok((src, g))
663    }
664
665    /// Build an **arrangement** graph as the persistent instance and route it to
666    /// the master bus. Unlike [`play`](Self::play), this keeps the per-node map so
667    /// [`schedule_triggers`](Self::schedule_triggers) can spawn voices into an
668    /// instrument-ref's voice-bus gain. Tears down any previous instance first.
669    pub fn play_arrangement(&mut self, arrangement: &Graph, looping: bool) -> Result<()> {
670        self.stop();
671        let t0 = self.ctx.current_time();
672        let built = build::build_graph(
673            &self.ctx,
674            arrangement,
675            &self.master,
676            &self.buffers,
677            &self.modules,
678            self.mic.as_ref(),
679            self.worklet_ready,
680            looping,
681            t0,
682        )?;
683        self.bus_nodes = built.nodes;
684        self.inner = built.inner;
685        self.sources = built.sources;
686        self.params = built.params;
687        if let Some(l) = &self.listener {
688            build::apply_listener(&self.ctx, l, t0);
689        }
690        for s in &self.sources {
691            let _ = s.start();
692        }
693        let _ = self.ctx.resume();
694        Ok(())
695    }
696
697    /// Schedule one pass of an arrangement's triggered notes starting at absolute
698    /// context time `at`. Each [`TriggerPart`] spawns a voice per note — an
699    /// instance of its instrument graph — feeding the part's target voice-bus gain
700    /// (found in the arrangement built by [`play_arrangement`](Self::play_arrangement)), whose audio then
701    /// flows through the arrangement to the Output. Scheduled on WebAudio's
702    /// sample-accurate clock; finished voices are reclaimed first; capped at
703    /// `MAX_SONG_VOICES`. Returns `(scheduled, end_time)`.
704    pub fn schedule_triggers(&mut self, parts: &[TriggerPart], at: f64) -> Result<(usize, f64)> {
705        // Reclaim song voices that have already finished, so a long loop doesn't
706        // accumulate dead nodes.
707        let now = self.ctx.current_time();
708        let mut i = 0;
709        while i < self.song_voices.len() {
710            if self.song_voices[i].stop_at <= now {
711                self.song_voices.swap_remove(i).teardown();
712            } else {
713                i += 1;
714            }
715        }
716
717        let before = self.song_voices.len();
718        let end_time = spawn_voices(
719            self.ctx.as_ref(),
720            &self.bus_nodes,
721            &self.buffers,
722            &self.modules,
723            self.worklet_ready,
724            self.mic.as_ref(),
725            parts,
726            at,
727            &mut self.song_voices,
728            MAX_SONG_VOICES,
729        )?;
730        let count = self.song_voices.len() - before;
731        let _ = self.ctx.resume();
732        Ok((count, end_time))
733    }
734
735    /// Apply a pass of control-lane automation to the live arrangement starting at
736    /// absolute context time `at`. Each [`ControlLanePart`] targets a node's
737    /// AudioParam (resolved from the arrangement built by `play_arrangement`) and
738    /// writes its points as a `setValueAtTime` anchor plus per-segment curves
739    /// (step / linear / exponential / smooth) over playback.
740    pub fn schedule_control(&self, parts: &[ControlLanePart], at: f64) {
741        apply_control(&self.params, parts, at);
742    }
743
744    /// Nudge a live AudioParam toward `value` while audio keeps playing — gliding
745    /// over ~`glide` seconds (`setTargetAtTime`, so sweeps are smooth and
746    /// click-free; pass `glide <= 0.0` to jump). No rebuild, so a held note / a
747    /// running drone keeps sounding. No-op where the node/param isn't present.
748    ///
749    /// This is the hook for **driving a playing sound from live application state**
750    /// — move a sound in 3D from a game entity's position, bend an oscillator's
751    /// pitch from a gauge, open a filter as something charges up. `node` is the
752    /// [`NodeId`] from the document; `param` is the WebAudio param name. Call
753    /// [`live_params`](Self::live_params) to discover the exact `(node, param)`
754    /// pairs currently controllable (or pick a node out of the document's graph by
755    /// kind).
756    ///
757    /// Controllable params by node kind:
758    /// - **Oscillator** — `"frequency"`, `"detune"`
759    /// - **Gain** — `"gain"`
760    /// - **BiquadFilter** — `"frequency"`, `"detune"`, `"Q"`, `"gain"`
761    /// - **Panner / SpatialOutput** — `"positionX"`, `"positionY"`, `"positionZ"`
762    ///   (SpatialOutput also `"gain"`); for the *listener*, use
763    ///   [`set_listener`](Self::set_listener)
764    /// - **AudioBufferSource** — `"playbackRate"`, `"detune"`
765    /// - **AudioWorklet** — every declared param, by its name
766    ///
767    /// ```no_run
768    /// # use awsm_audio_player::Player;
769    /// # use awsm_audio_schema::{NodeKind, SampleLibrary, SampleId};
770    /// # fn demo(player: &Player, lib: &SampleLibrary, sample: SampleId) {
771    /// // Find the spatial output node in the played sample, then steer it each frame.
772    /// if let Some(out) = lib.sample(sample).and_then(|s|
773    ///     s.graph.nodes.iter().find(|n| matches!(n.kind, NodeKind::SpatialOutput(_))))
774    /// {
775    ///     player.set_param_live(out.id, "positionX", 3.0, 0.05); // glide to x=3
776    /// }
777    /// # }
778    /// ```
779    pub fn set_param_live(&self, node: NodeId, param: &str, value: f32, glide: f64) {
780        let now = self.ctx.current_time();
781        let apply = |params: &[(NodeId, Vec<(&'static str, web_sys::AudioParam)>)]| {
782            if let Some(p) = params
783                .iter()
784                .find(|(id, _)| *id == node)
785                .and_then(|(_, ps)| ps.iter().find(|(name, _)| *name == param).map(|(_, p)| p))
786            {
787                if glide <= 0.0 {
788                    let _ = p.set_value_at_time(value, now);
789                } else {
790                    // time-constant ≈ glide/3 → near-complete move within `glide`.
791                    let _ = p.set_target_at_time(value, now, glide / 3.0);
792                }
793            }
794        };
795        apply(&self.params);
796    }
797
798    /// Every live, controllable `(node, [param names])` in the currently-playing
799    /// graph — exactly the targets [`set_param_live`](Self::set_param_live)
800    /// accepts. Empty until something is playing.
801    ///
802    /// This is the **discoverable** way to do live control: after
803    /// [`play_document`](Self::play_document), ask the engine what's adjustable
804    /// instead of inspecting the document or memorizing per-node params. Pair it
805    /// with the node kinds in the document to build, say, a slider per param.
806    ///
807    /// ```no_run
808    /// # use awsm_audio_player::Player;
809    /// # fn demo(player: &Player) {
810    /// for (node, params) in player.live_params() {
811    ///     for name in params {
812    ///         // e.g. surface a control, or drive it from app state:
813    ///         player.set_param_live(node, name, 1.0, 0.02);
814    ///     }
815    /// }
816    /// # }
817    /// ```
818    ///
819    /// (Reflects the main graph's nodes — a prewired sound, an arrangement graph.
820    /// Per-note voices spawned by a sequencer aren't listed individually.)
821    pub fn live_params(&self) -> Vec<(NodeId, Vec<&'static str>)> {
822        self.params
823            .iter()
824            .map(|(id, ps)| (*id, ps.iter().map(|(name, _)| *name).collect()))
825            .collect()
826    }
827
828    /// The current (base) value of a live param — for initializing a UI control to
829    /// the sound's actual setting. `None` if the node/param isn't live.
830    pub fn param_value(&self, node: NodeId, param: &str) -> Option<f32> {
831        self.params
832            .iter()
833            .find(|(id, _)| *id == node)
834            .and_then(|(_, ps)| {
835                ps.iter()
836                    .find(|(name, _)| *name == param)
837                    .map(|(_, p)| p.value())
838            })
839    }
840
841    /// Number of scheduled song voices currently alive (for "is sound playing").
842    pub fn voice_count(&self) -> usize {
843        self.song_voices.len()
844    }
845
846    /// Number of time-domain samples the analyser exposes per frame.
847    pub fn waveform_len(&self) -> usize {
848        self.analyser.fft_size() as usize
849    }
850
851    /// Peak output level right now, 0..1 (analyser deviation from silence). A
852    /// reliable "is sound coming out" probe that doesn't depend on the canvas.
853    pub fn peak(&self) -> f32 {
854        let mut buf = vec![128u8; self.analyser.fft_size() as usize];
855        self.analyser.get_byte_time_domain_data(&mut buf);
856        buf.iter()
857            .map(|&b| (f32::from(b) - 128.0).abs() / 128.0)
858            .fold(0.0, f32::max)
859    }
860
861    /// The context's playback state (`"suspended"` / `"running"` / `"closed"`).
862    pub fn context_state(&self) -> String {
863        format!("{:?}", self.ctx.state())
864    }
865
866    /// Copy the latest time-domain waveform (0..=255, 128 = silence) into `buf`.
867    pub fn read_waveform(&self, buf: &mut [u8]) {
868        self.analyser.get_byte_time_domain_data(buf);
869    }
870}