Skip to main content

rust_synth/audio/
engine.rs

1//! cpal output stream wiring.
2//!
3//! 8 pre-allocated track slots. Audio callback pulls stereo samples from
4//! a FunDSP `Net` built once from all 8. Dormant slots are simply muted
5//! — no reallocation, no graph hot-swap. Sample ring buffer captures
6//! output for the TUI oscilloscope (producer-side only lock).
7
8use anyhow::{Context, Result};
9use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
10use cpal::{Device, Stream, StreamConfig};
11use fundsp::hacker::*;
12use parking_lot::Mutex;
13use std::collections::VecDeque;
14use std::sync::Arc;
15
16use super::preset::{master_bus, GlobalParams, Preset, PresetKind};
17use super::track::Track;
18use crate::math::harmony::golden_freq;
19use crate::recording::RecorderState;
20
21/// Max tracks pre-allocated. Raise = more CPU, more slots.
22pub const MAX_TRACKS: usize = 8;
23
24/// Ring buffer of stereo samples for the oscilloscope (decimated).
25pub const SCOPE_CAPACITY: usize = 512;
26/// Keep one sample per N audio samples.
27pub const SCOPE_DECIMATION: usize = 32;
28
29pub type ScopeBuffer = Arc<Mutex<VecDeque<(f32, f32)>>>;
30pub type SharedGraph = Arc<Mutex<Net>>;
31
32/// Handle the TUI keeps alive for the lifetime of the app.
33pub struct EngineHandle {
34    pub tracks: Arc<Mutex<Vec<Track>>>,
35    pub global: GlobalParams,
36    pub peak_l: Shared,
37    pub peak_r: Shared,
38    pub sample_rate: f32,
39    pub scope: ScopeBuffer,
40    pub phase_clock: Shared,
41    pub recorder: Arc<RecorderState>,
42    /// Master DSP graph. Exposed behind a mutex so the TUI can swap it
43    /// wholesale when the user changes a track's preset kind at runtime.
44    /// Lock contention is tiny: audio callback holds it for one buffer
45    /// (~10 ms at 48 kHz / 512-sample buffer) and UI rebuilds take ~5 ms.
46    graph: SharedGraph,
47    _stream: Stream,
48}
49
50pub struct AudioEngine;
51
52impl AudioEngine {
53    pub fn start(initial_tracks: Vec<Track>) -> Result<EngineHandle> {
54        assert!(
55            initial_tracks.len() == MAX_TRACKS,
56            "expected exactly {MAX_TRACKS} pre-allocated tracks, got {}",
57            initial_tracks.len()
58        );
59
60        let host = cpal::default_host();
61        let device = host
62            .default_output_device()
63            .context("no default output audio device")?;
64        let config: StreamConfig = device.default_output_config()?.into();
65        let sample_rate = config.sample_rate.0 as f32;
66        let channels = config.channels as usize;
67
68        let global = GlobalParams::default();
69        let peak_l = shared(0.0);
70        let peak_r = shared(0.0);
71        let phase_clock = shared(0.0);
72        let scope: ScopeBuffer = Arc::new(Mutex::new(VecDeque::with_capacity(SCOPE_CAPACITY)));
73        let tracks = Arc::new(Mutex::new(initial_tracks));
74        let recorder = RecorderState::new(sample_rate as u32);
75
76        let mut graph = build_master(&tracks.lock(), &global);
77        graph.set_sample_rate(sample_rate as f64);
78        let graph: SharedGraph = Arc::new(Mutex::new(graph));
79
80        let stream = start_stream(
81            device,
82            config,
83            channels,
84            sample_rate,
85            graph.clone(),
86            global.master_gain.clone(),
87            peak_l.clone(),
88            peak_r.clone(),
89            scope.clone(),
90            phase_clock.clone(),
91            recorder.clone(),
92        )?;
93
94        Ok(EngineHandle {
95            tracks,
96            global,
97            peak_l,
98            peak_r,
99            sample_rate,
100            scope,
101            phase_clock,
102            recorder,
103            graph,
104            _stream: stream,
105        })
106    }
107}
108
109impl EngineHandle {
110    /// Rebuild the master DSP graph from the current track list. Call
111    /// this after mutating any `track.kind`. Cheap enough for live use —
112    /// the audio callback sees the new graph on its next buffer.
113    ///
114    /// NB: the old graph's internal state (reverb tails, oscillator
115    /// phases) is discarded. There's a small audible discontinuity
116    /// during preset swaps; acceptable given how rarely kind changes.
117    pub fn rebuild_graph(&self) {
118        let tracks = self.tracks.lock();
119        let mut new_graph = build_master(&tracks, &self.global);
120        drop(tracks);
121        new_graph.set_sample_rate(self.sample_rate as f64);
122        *self.graph.lock() = new_graph;
123    }
124}
125
126fn build_master(tracks: &[Track], g: &GlobalParams) -> Net {
127    let mut summed: Option<Net> = None;
128    for t in tracks {
129        let node = Preset::build(t.kind, &t.params, g);
130        summed = Some(match summed {
131            Some(acc) => acc + node,
132            None => node,
133        });
134    }
135    let summed = summed.unwrap_or_else(|| Net::wrap(Box::new(zero() | zero())));
136
137    // Pipe the stereo sum through the master bus (variable lowpass +
138    // soft limiter). This is where "highs punch" gets tamed before the
139    // signal reaches cpal.
140    summed >> master_bus(g.brightness.clone())
141}
142
143#[allow(clippy::too_many_arguments)]
144fn start_stream(
145    device: Device,
146    config: StreamConfig,
147    channels: usize,
148    sample_rate: f32,
149    graph: SharedGraph,
150    master: Shared,
151    peak_l: Shared,
152    peak_r: Shared,
153    scope: ScopeBuffer,
154    phase_clock: Shared,
155    recorder: Arc<RecorderState>,
156) -> Result<Stream> {
157    let err_fn = |err| tracing::error!("audio stream error: {err}");
158    let mut env_l = 0.0f32;
159    let mut env_r = 0.0f32;
160    let fall = 0.9995f32;
161    let dt: f64 = 1.0 / sample_rate as f64;
162    let mut t: f64 = 0.0;
163    let mut decim = 0usize;
164
165    let stream = device.build_output_stream(
166        &config,
167        move |data: &mut [f32], _| {
168            let m = master.value();
169            let mut pending: [(f32, f32); 32] = [(0.0, 0.0); 32];
170            let mut pending_n = 0usize;
171            // Lock the shared graph for the whole buffer — a few µs
172            // longer than per-sample locking, but no extra cost.
173            let mut graph = graph.lock();
174
175            for frame in data.chunks_mut(channels) {
176                let (lo, ro) = graph.get_stereo();
177                let l = lo * m;
178                let r = ro * m;
179                env_l = (env_l * fall).max(l.abs());
180                env_r = (env_r * fall).max(r.abs());
181
182                for (ch, slot) in frame.iter_mut().enumerate() {
183                    *slot = if ch & 1 == 0 { l } else { r };
184                }
185
186                // Record master output if active. Lock is held for a
187                // handful of ns per frame — safe at 48 kHz.
188                recorder.push_frame(l, r);
189
190                decim = decim.wrapping_add(1);
191                if decim.is_multiple_of(SCOPE_DECIMATION) && pending_n < pending.len() {
192                    pending[pending_n] = (l, r);
193                    pending_n += 1;
194                }
195
196                t += dt;
197            }
198
199            // Single lock per callback, not per sample.
200            if pending_n > 0 {
201                let mut scope = scope.lock();
202                for &s in &pending[..pending_n] {
203                    if scope.len() == SCOPE_CAPACITY {
204                        scope.pop_front();
205                    }
206                    scope.push_back(s);
207                }
208            }
209
210            peak_l.set_value(env_l);
211            peak_r.set_value(env_r);
212            phase_clock.set_value(t as f32);
213        },
214        err_fn,
215        None,
216    )?;
217    stream.play()?;
218    Ok(stream)
219}
220
221/// Default 8-track set: 3 active + 5 dormant, rooted on golden-ratio frequencies.
222pub fn default_track_set() -> Vec<Track> {
223    let root = 55.0f32; // A1
224    let mut tracks = Vec::with_capacity(MAX_TRACKS);
225
226    // Four active voices — full ambient layout by default.
227    tracks.push(Track::new(0, "Pad",       PresetKind::PadZimmer, golden_freq(root, 0)));
228    tracks.push(Track::new(1, "Bass",      PresetKind::BassPulse, golden_freq(root, 0)));
229    tracks.push(Track::new(2, "Heartbeat", PresetKind::Heartbeat, golden_freq(root, 0)));
230    tracks.push(Track::new(3, "Drone",     PresetKind::DroneSub,  golden_freq(root, -1)));
231    tracks[3].params.gain.set_value(0.32);
232    tracks[3].params.reverb_mix.set_value(0.7);
233
234    // Dormant slots — one per remaining preset kind so `a` shows every
235    // available voice. Names mirror the kind label; when activated the
236    // user immediately knows what they just turned on.
237    tracks.push(Track::dormant(4, "Shimmer",  PresetKind::Shimmer,  golden_freq(root, 1)));
238    tracks.push(Track::dormant(5, "Bell",     PresetKind::Bell,     golden_freq(root, 2)));
239    tracks.push(Track::dormant(6, "SuperSaw", PresetKind::SuperSaw, golden_freq(root, -2)));
240    tracks.push(Track::dormant(7, "Pluck",    PresetKind::PluckSaw, golden_freq(root, 1)));
241
242    tracks
243}