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)>>>;
30
31/// Handle the TUI keeps alive for the lifetime of the app.
32pub struct EngineHandle {
33    pub tracks: Arc<Mutex<Vec<Track>>>,
34    pub global: GlobalParams,
35    pub peak_l: Shared,
36    pub peak_r: Shared,
37    pub sample_rate: f32,
38    pub scope: ScopeBuffer,
39    pub phase_clock: Shared,
40    pub recorder: Arc<RecorderState>,
41    _stream: Stream,
42}
43
44pub struct AudioEngine;
45
46impl AudioEngine {
47    pub fn start(initial_tracks: Vec<Track>) -> Result<EngineHandle> {
48        assert!(
49            initial_tracks.len() == MAX_TRACKS,
50            "expected exactly {MAX_TRACKS} pre-allocated tracks, got {}",
51            initial_tracks.len()
52        );
53
54        let host = cpal::default_host();
55        let device = host
56            .default_output_device()
57            .context("no default output audio device")?;
58        let config: StreamConfig = device.default_output_config()?.into();
59        let sample_rate = config.sample_rate.0 as f32;
60        let channels = config.channels as usize;
61
62        let global = GlobalParams::default();
63        let peak_l = shared(0.0);
64        let peak_r = shared(0.0);
65        let phase_clock = shared(0.0);
66        let scope: ScopeBuffer = Arc::new(Mutex::new(VecDeque::with_capacity(SCOPE_CAPACITY)));
67        let tracks = Arc::new(Mutex::new(initial_tracks));
68        let recorder = RecorderState::new(sample_rate as u32);
69
70        let mut graph = build_master(&tracks.lock(), &global);
71        graph.set_sample_rate(sample_rate as f64);
72
73        let stream = start_stream(
74            device,
75            config,
76            channels,
77            sample_rate,
78            graph,
79            global.master_gain.clone(),
80            peak_l.clone(),
81            peak_r.clone(),
82            scope.clone(),
83            phase_clock.clone(),
84            recorder.clone(),
85        )?;
86
87        Ok(EngineHandle {
88            tracks,
89            global,
90            peak_l,
91            peak_r,
92            sample_rate,
93            scope,
94            phase_clock,
95            recorder,
96            _stream: stream,
97        })
98    }
99}
100
101fn build_master(tracks: &[Track], g: &GlobalParams) -> Net {
102    let mut summed: Option<Net> = None;
103    for t in tracks {
104        let node = Preset::build(t.kind, &t.params, g);
105        summed = Some(match summed {
106            Some(acc) => acc + node,
107            None => node,
108        });
109    }
110    let summed = summed.unwrap_or_else(|| Net::wrap(Box::new(zero() | zero())));
111
112    // Pipe the stereo sum through the master bus (variable lowpass +
113    // soft limiter). This is where "highs punch" gets tamed before the
114    // signal reaches cpal.
115    summed >> master_bus(g.brightness.clone())
116}
117
118#[allow(clippy::too_many_arguments)]
119fn start_stream(
120    device: Device,
121    config: StreamConfig,
122    channels: usize,
123    sample_rate: f32,
124    mut graph: Net,
125    master: Shared,
126    peak_l: Shared,
127    peak_r: Shared,
128    scope: ScopeBuffer,
129    phase_clock: Shared,
130    recorder: Arc<RecorderState>,
131) -> Result<Stream> {
132    let err_fn = |err| tracing::error!("audio stream error: {err}");
133    let mut env_l = 0.0f32;
134    let mut env_r = 0.0f32;
135    let fall = 0.9995f32;
136    // f64 accumulator — precise for hours, avoids the f32 drift that
137    // causes sub-rate LFOs to alias after ~5 minutes at 48 kHz.
138    let dt: f64 = 1.0 / sample_rate as f64;
139    let mut t: f64 = 0.0;
140    let mut decim = 0usize;
141
142    let stream = device.build_output_stream(
143        &config,
144        move |data: &mut [f32], _| {
145            let m = master.value();
146            let mut pending: [(f32, f32); 32] = [(0.0, 0.0); 32];
147            let mut pending_n = 0usize;
148
149            for frame in data.chunks_mut(channels) {
150                let (lo, ro) = graph.get_stereo();
151                // hacker uses f64 internally (time counter precise for
152                // hours); get_stereo downcasts to f32 at the boundary.
153                let l = lo * m;
154                let r = ro * m;
155                env_l = (env_l * fall).max(l.abs());
156                env_r = (env_r * fall).max(r.abs());
157
158                for (ch, slot) in frame.iter_mut().enumerate() {
159                    *slot = if ch & 1 == 0 { l } else { r };
160                }
161
162                // Record master output if active. Lock is held for a
163                // handful of ns per frame — safe at 48 kHz.
164                recorder.push_frame(l, r);
165
166                decim = decim.wrapping_add(1);
167                if decim.is_multiple_of(SCOPE_DECIMATION) && pending_n < pending.len() {
168                    pending[pending_n] = (l, r);
169                    pending_n += 1;
170                }
171
172                t += dt;
173            }
174
175            // Single lock per callback, not per sample.
176            if pending_n > 0 {
177                let mut scope = scope.lock();
178                for &s in &pending[..pending_n] {
179                    if scope.len() == SCOPE_CAPACITY {
180                        scope.pop_front();
181                    }
182                    scope.push_back(s);
183                }
184            }
185
186            peak_l.set_value(env_l);
187            peak_r.set_value(env_r);
188            phase_clock.set_value(t as f32);
189        },
190        err_fn,
191        None,
192    )?;
193    stream.play()?;
194    Ok(stream)
195}
196
197/// Default 8-track set: 3 active + 5 dormant, rooted on golden-ratio frequencies.
198pub fn default_track_set() -> Vec<Track> {
199    let root = 55.0f32; // A1
200    let mut tracks = Vec::with_capacity(MAX_TRACKS);
201
202    // Four active voices — full ambient layout by default.
203    tracks.push(Track::new(0, "Pad · root",   PresetKind::PadZimmer, golden_freq(root, 0)));
204    tracks.push(Track::new(1, "Bass",         PresetKind::BassPulse, golden_freq(root, 0)));
205    tracks.push(Track::new(2, "Heartbeat",    PresetKind::Heartbeat, golden_freq(root, 0)));
206    tracks.push(Track::new(3, "Sub Drone",    PresetKind::DroneSub,  golden_freq(root, -1)));
207    // Drone starts a touch quieter so it sits under the bass on launch.
208    tracks[3].params.gain.set_value(0.32);
209    tracks[3].params.reverb_mix.set_value(0.7);
210
211    // Dormant slots — `a` activates the next one.
212    tracks.push(Track::dormant(4, "— empty",  PresetKind::Shimmer,   golden_freq(root, 1)));
213    tracks.push(Track::dormant(5, "— empty",  PresetKind::BassPulse, golden_freq(root, -1)));
214    tracks.push(Track::dormant(6, "— empty",  PresetKind::Shimmer,   golden_freq(root, 2)));
215    tracks.push(Track::dormant(7, "— empty",  PresetKind::PadZimmer, golden_freq(root, 1)));
216
217    tracks
218}