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}