rust-synth 0.23.0

Terminal modular ambient synthesizer — FunDSP + Ratatui. Long cinematic pads, Euclidean drum sequencer, per-track LFO, Valhalla-Supermassive-style reverb, genetic evolution coupled to Conway's Game of Life, TOML presets, FLAC recording.
Documentation
//! cpal output stream wiring.
//!
//! 8 pre-allocated track slots. Audio callback pulls stereo samples from
//! a FunDSP `Net` built once from all 8. Dormant slots are simply muted
//! — no reallocation, no graph hot-swap. Sample ring buffer captures
//! output for the TUI oscilloscope (producer-side only lock).

use anyhow::{Context, Result};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{Device, Stream, StreamConfig};
use fundsp::hacker::*;
use parking_lot::Mutex;
use std::collections::VecDeque;
use std::sync::Arc;

use super::preset::{master_bus, GlobalParams, Preset, PresetKind};
use super::track::Track;
use crate::math::harmony::golden_freq;
use crate::recording::RecorderState;

/// Max tracks pre-allocated. Raise = more CPU, more slots.
pub const MAX_TRACKS: usize = 8;

/// Ring buffer of stereo samples for the oscilloscope (decimated).
pub const SCOPE_CAPACITY: usize = 512;
/// Keep one sample per N audio samples.
pub const SCOPE_DECIMATION: usize = 32;

pub type ScopeBuffer = Arc<Mutex<VecDeque<(f32, f32)>>>;
pub type SharedGraph = Arc<Mutex<Net>>;

/// Handle the TUI keeps alive for the lifetime of the app.
pub struct EngineHandle {
    pub tracks: Arc<Mutex<Vec<Track>>>,
    pub global: GlobalParams,
    pub peak_l: Shared,
    pub peak_r: Shared,
    pub sample_rate: f32,
    pub scope: ScopeBuffer,
    pub phase_clock: Shared,
    pub recorder: Arc<RecorderState>,
    /// Master DSP graph. Exposed behind a mutex so the TUI can swap it
    /// wholesale when the user changes a track's preset kind at runtime.
    /// Lock contention is tiny: audio callback holds it for one buffer
    /// (~10 ms at 48 kHz / 512-sample buffer) and UI rebuilds take ~5 ms.
    graph: SharedGraph,
    _stream: Stream,
}

pub struct AudioEngine;

impl AudioEngine {
    pub fn start(initial_tracks: Vec<Track>) -> Result<EngineHandle> {
        assert!(
            initial_tracks.len() == MAX_TRACKS,
            "expected exactly {MAX_TRACKS} pre-allocated tracks, got {}",
            initial_tracks.len()
        );

        let host = cpal::default_host();
        let device = host
            .default_output_device()
            .context("no default output audio device")?;
        let config: StreamConfig = device.default_output_config()?.into();
        let sample_rate = config.sample_rate.0 as f32;
        let channels = config.channels as usize;

        let global = GlobalParams::default();
        let peak_l = shared(0.0);
        let peak_r = shared(0.0);
        let phase_clock = shared(0.0);
        let scope: ScopeBuffer = Arc::new(Mutex::new(VecDeque::with_capacity(SCOPE_CAPACITY)));
        let tracks = Arc::new(Mutex::new(initial_tracks));
        let recorder = RecorderState::new(sample_rate as u32);

        let mut graph = build_master(&tracks.lock(), &global);
        graph.set_sample_rate(sample_rate as f64);
        let graph: SharedGraph = Arc::new(Mutex::new(graph));

        let stream = start_stream(
            device,
            config,
            channels,
            sample_rate,
            graph.clone(),
            global.master_gain.clone(),
            peak_l.clone(),
            peak_r.clone(),
            scope.clone(),
            phase_clock.clone(),
            recorder.clone(),
        )?;

        Ok(EngineHandle {
            tracks,
            global,
            peak_l,
            peak_r,
            sample_rate,
            scope,
            phase_clock,
            recorder,
            graph,
            _stream: stream,
        })
    }
}

impl EngineHandle {
    /// Rebuild the master DSP graph from the current track list. Call
    /// this after mutating any `track.kind`. Cheap enough for live use —
    /// the audio callback sees the new graph on its next buffer.
    ///
    /// NB: the old graph's internal state (reverb tails, oscillator
    /// phases) is discarded. There's a small audible discontinuity
    /// during preset swaps; acceptable given how rarely kind changes.
    pub fn rebuild_graph(&self) {
        let tracks = self.tracks.lock();
        let mut new_graph = build_master(&tracks, &self.global);
        drop(tracks);
        new_graph.set_sample_rate(self.sample_rate as f64);
        *self.graph.lock() = new_graph;
    }
}

fn build_master(tracks: &[Track], g: &GlobalParams) -> Net {
    let mut summed: Option<Net> = None;
    for t in tracks {
        let node = Preset::build(t.kind, &t.params, g);
        summed = Some(match summed {
            Some(acc) => acc + node,
            None => node,
        });
    }
    let summed = summed.unwrap_or_else(|| Net::wrap(Box::new(zero() | zero())));

    // Pipe the stereo sum through the master bus (variable lowpass +
    // soft limiter). This is where "highs punch" gets tamed before the
    // signal reaches cpal.
    summed >> master_bus(g.brightness.clone())
}

#[allow(clippy::too_many_arguments)]
fn start_stream(
    device: Device,
    config: StreamConfig,
    channels: usize,
    sample_rate: f32,
    graph: SharedGraph,
    master: Shared,
    peak_l: Shared,
    peak_r: Shared,
    scope: ScopeBuffer,
    phase_clock: Shared,
    recorder: Arc<RecorderState>,
) -> Result<Stream> {
    let err_fn = |err| tracing::error!("audio stream error: {err}");
    let mut env_l = 0.0f32;
    let mut env_r = 0.0f32;
    let fall = 0.9995f32;
    let dt: f64 = 1.0 / sample_rate as f64;
    let mut t: f64 = 0.0;
    let mut decim = 0usize;

    let stream = device.build_output_stream(
        &config,
        move |data: &mut [f32], _| {
            let m = master.value();
            let mut pending: [(f32, f32); 32] = [(0.0, 0.0); 32];
            let mut pending_n = 0usize;
            // Lock the shared graph for the whole buffer — a few µs
            // longer than per-sample locking, but no extra cost.
            let mut graph = graph.lock();

            for frame in data.chunks_mut(channels) {
                let (lo, ro) = graph.get_stereo();
                let l = lo * m;
                let r = ro * m;
                env_l = (env_l * fall).max(l.abs());
                env_r = (env_r * fall).max(r.abs());

                for (ch, slot) in frame.iter_mut().enumerate() {
                    *slot = if ch & 1 == 0 { l } else { r };
                }

                // Record master output if active. Lock is held for a
                // handful of ns per frame — safe at 48 kHz.
                recorder.push_frame(l, r);

                decim = decim.wrapping_add(1);
                if decim.is_multiple_of(SCOPE_DECIMATION) && pending_n < pending.len() {
                    pending[pending_n] = (l, r);
                    pending_n += 1;
                }

                t += dt;
            }

            // Single lock per callback, not per sample.
            if pending_n > 0 {
                let mut scope = scope.lock();
                for &s in &pending[..pending_n] {
                    if scope.len() == SCOPE_CAPACITY {
                        scope.pop_front();
                    }
                    scope.push_back(s);
                }
            }

            peak_l.set_value(env_l);
            peak_r.set_value(env_r);
            phase_clock.set_value(t as f32);
        },
        err_fn,
        None,
    )?;
    stream.play()?;
    Ok(stream)
}

/// Default 8-track set: 3 active + 5 dormant, rooted on golden-ratio frequencies.
pub fn default_track_set() -> Vec<Track> {
    let root = 55.0f32; // A1
    let mut tracks = Vec::with_capacity(MAX_TRACKS);

    // Four active voices — full ambient layout by default.
    tracks.push(Track::new(0, "Pad",       PresetKind::PadZimmer, golden_freq(root, 0)));
    tracks.push(Track::new(1, "Bass",      PresetKind::BassPulse, golden_freq(root, 0)));
    tracks.push(Track::new(2, "Heartbeat", PresetKind::Heartbeat, golden_freq(root, 0)));
    tracks.push(Track::new(3, "Drone",     PresetKind::DroneSub,  golden_freq(root, -1)));
    tracks[3].params.gain.set_value(0.32);
    tracks[3].params.reverb_mix.set_value(0.7);

    // Dormant slots — one per remaining preset kind so `a` shows every
    // available voice. Names mirror the kind label; when activated the
    // user immediately knows what they just turned on.
    tracks.push(Track::dormant(4, "Shimmer",  PresetKind::Shimmer,  golden_freq(root, 1)));
    tracks.push(Track::dormant(5, "Bell",     PresetKind::Bell,     golden_freq(root, 2)));
    tracks.push(Track::dormant(6, "SuperSaw", PresetKind::SuperSaw, golden_freq(root, -2)));
    tracks.push(Track::dormant(7, "Pluck",    PresetKind::PluckSaw, golden_freq(root, 1)));

    tracks
}