//! ZHILA audio engine, P0. //! //! A real cpal stream with a fixed voice pool, a two-bar phrase, per-vein //! levels and a bar position. Everything that runs in the callback is //! allocation-free. The control surface is lock-free on the audio side. use std::f32::consts::{FRAC_PI_4, TAU}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, Mutex}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{BufferSize, FromSample, SampleFormat, SizedSample, Stream, StreamConfig}; use rtrb::{Consumer, Producer, RingBuffer}; use thiserror::Error; use zhila_core::{Engine as EngineControl, MaterialSnapshot}; use zhila_protocol::{ AudioInfo, LOOP_STEPS, MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern, STEPS_PER_BAR, VEIN_COUNT, VeinId, }; const VOICES: usize = 32; /// (vein index, velocity, optional (core index, face index)) for one step. type StepTrigger = (usize, u8, Option<(usize, u8)>); enum AudioCommand { Play, Stop, Strike { vein: u8, velocity: f32, }, StrikeFace { core: String, face: u8, velocity: f32, }, SetTempo(f32), SetGain { vein: u8, gain: f32, }, SetMaterial(Arc), } struct Levels { master: AtomicU32, veins: [AtomicU32; VEIN_COUNT], bar: AtomicU32, } impl Levels { fn new() -> Self { Self { master: AtomicU32::new(0.0f32.to_bits()), veins: std::array::from_fn(|_| AtomicU32::new(0.0f32.to_bits())), bar: AtomicU32::new(1.0f32.to_bits()), } } fn store(&self, frame: &MeterFrame) { self.master.store(frame.master.to_bits(), Ordering::Relaxed); self.bar.store(frame.bar.to_bits(), Ordering::Relaxed); for (level, slot) in frame.veins.iter().zip(self.veins.iter()) { slot.store(level.to_bits(), Ordering::Relaxed); } } fn load(&self) -> MeterFrame { let master = f32::from_bits(self.master.load(Ordering::Relaxed)); let bar = f32::from_bits(self.bar.load(Ordering::Relaxed)); let mut veins = [0.0; VEIN_COUNT]; for (level, slot) in veins.iter_mut().zip(self.veins.iter()) { *level = f32::from_bits(slot.load(Ordering::Relaxed)); } MeterFrame { bar, master, veins } } } struct SampleVoice { core_index: usize, start: usize, end: usize, position: f64, rate: f64, } struct Voice { active: bool, vein: usize, phase: f32, freq: f32, t: f32, attack: f32, decay: f32, velocity: f32, gain_l: f32, gain_r: f32, lp: f32, hp: f32, prev: f32, rng: u32, sample: Option, } impl Voice { fn inactive() -> Self { Self { active: false, vein: 0, phase: 0.0, freq: 0.0, t: 0.0, attack: 0.01, decay: 4.0, velocity: 0.0, gain_l: 0.707, gain_r: 0.707, lp: 0.0, hp: 0.0, prev: 0.0, rng: 0x9e37_79b9, sample: None, } } fn trigger(&mut self, vein: VeinId, velocity: f32, seed: u32) { self.active = true; self.vein = vein.index(); self.phase = 0.0; self.t = 0.0; self.velocity = velocity.clamp(0.0, 1.0); self.rng = seed | 1; self.lp = 0.0; self.hp = 0.0; self.prev = 0.0; self.sample = None; let (freq, attack, decay) = match vein { VeinId::Bass => (55.0, 0.004, 3.2), VeinId::Crack => (0.0, 0.001, 38.0), VeinId::Voice => (220.0, 0.09, 1.1), VeinId::Noise => (0.0, 0.004, 5.0), VeinId::Silt => (1318.5, 0.002, 14.0), }; self.freq = freq; self.attack = attack; self.decay = decay; let (gain_l, gain_r) = pan_for(vein); self.gain_l = gain_l; self.gain_r = gain_r; } fn trigger_sample( &mut self, vein: VeinId, velocity: f32, core_index: usize, start: usize, end: usize, rate: f64, ) { self.active = true; self.vein = vein.index(); self.phase = 0.0; self.freq = 0.0; self.t = 0.0; self.velocity = velocity.clamp(0.0, 1.0); self.attack = 0.004; self.decay = 4.0; self.lp = 0.0; self.hp = 0.0; self.prev = 0.0; let (gain_l, gain_r) = pan_for(vein); self.gain_l = gain_l; self.gain_r = gain_r; self.sample = Some(SampleVoice { core_index, start, end, position: start as f64, rate, }); } fn noise(&mut self) -> f32 { self.rng = self.rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); (self.rng as f32 / u32::MAX as f32) * 2.0 - 1.0 } fn next(&mut self, sample_rate: f32, material: Option<&MaterialSnapshot>) -> f32 { self.t += 1.0 / sample_rate; if let Some(voice) = self.sample.as_mut() { let Some(core) = material.and_then(|material| material.cores.get(voice.core_index)) else { self.active = false; return 0.0; }; if voice.position >= voice.end as f64 { self.active = false; return 0.0; } let index = voice.position.floor() as usize; let fraction = (voice.position - index as f64) as f32; let first = core.samples.get(index).copied().unwrap_or(0.0); let second = core.samples.get(index + 1).copied().unwrap_or(first); let raw = first + (second - first) * fraction; let remaining = ((voice.end as f64 - voice.position) / voice.rate / sample_rate as f64) as f32; let fade_in = (self.t / 0.004).min(1.0); let fade_out = (remaining / 0.004).min(1.0); voice.position += voice.rate; if voice.position >= voice.end as f64 { self.active = false; } let _ = voice.start; return raw * fade_in.min(fade_out) * self.velocity; } let attack = (self.t / self.attack).min(1.0); let env = attack * (-self.t * self.decay).exp(); if env < 0.0002 || self.t > 8.0 { self.active = false; return 0.0; } let sample = match self.vein { 0 => { let fundamental = (TAU * self.phase).sin(); let sub = (TAU * self.phase * 0.5).sin(); fundamental + sub * 0.45 } 1 => { let white = self.noise(); let hp = white - self.prev + 0.94 * self.hp; self.prev = white; self.hp = hp; hp * 0.7 } 2 => { let fundamental = (TAU * self.phase).sin(); let overtone = (TAU * self.phase * 2.0).sin(); fundamental + overtone * 0.18 } 3 => { let white = self.noise(); self.lp += (white - self.lp) * 0.06; self.lp } _ => (TAU * self.phase).sin(), }; if self.freq > 0.0 { self.phase += self.freq / sample_rate; if self.phase >= 1.0 { self.phase -= 1.0; } } sample * env * self.velocity } } fn pan_for(vein: VeinId) -> (f32, f32) { let pan = match vein { VeinId::Bass => 0.0, VeinId::Crack => 0.35, VeinId::Voice => -0.25, VeinId::Noise => 0.55, VeinId::Silt => -0.45, }; let angle = (pan + 1.0) * FRAC_PI_4; (angle.cos(), angle.sin()) } struct AudioState { rx: Consumer, sample_rate: f32, channels: usize, bpm: f32, playing: bool, step: usize, pending: f64, fallback: Pattern, material: Option>, voices: [Voice; VOICES], gains: [f32; VEIN_COUNT], master_env: f32, vein_env: [f32; VEIN_COUNT], bar: f32, rng: u32, } impl AudioState { fn new(sample_rate: u32, channels: u16, pattern: &Pattern, rx: Consumer) -> Self { Self { rx, sample_rate: sample_rate as f32, channels: channels as usize, bpm: 128.0, playing: false, step: 0, pending: 0.0, fallback: pattern.clone(), material: None, voices: std::array::from_fn(|_| Voice::inactive()), gains: zhila_core::INITIAL_GAINS, master_env: 0.0, vein_env: [0.0; VEIN_COUNT], bar: 1.0, rng: 0x51ed_2701, } } fn step_samples(&self) -> f64 { self.sample_rate as f64 * 60.0 / self.bpm as f64 / 2.0 } fn handle(&mut self, command: AudioCommand) { match command { AudioCommand::Play => { self.playing = true; self.step = 0; self.pending = 0.0; self.bar = 1.0; } AudioCommand::Stop => { self.playing = false; self.step = 0; self.pending = 0.0; self.bar = 1.0; self.master_env = 0.0; self.vein_env = [0.0; VEIN_COUNT]; for voice in &mut self.voices { voice.active = false; } } AudioCommand::Strike { vein, velocity } => { let Some(vein) = VeinId::from_index(vein as usize) else { return; }; self.trigger(vein, velocity); } AudioCommand::StrikeFace { core, face, velocity, } => { let Some(material) = self.material.as_ref().cloned() else { return; }; let Some(core_index) = material .cores .iter() .position(|candidate| candidate.id == core) else { return; }; let vein_index = material .pattern .rows .iter() .find(|row| { row.face.as_ref().is_some_and(|reference| { reference.core == core && reference.face == face }) }) .map(|row| row.vein.index()) .unwrap_or(0); self.trigger_sample(core_index, face, vein_index, velocity); } AudioCommand::SetTempo(bpm) => { self.bpm = bpm.clamp(MIN_BPM, MAX_BPM); } AudioCommand::SetGain { vein, gain } => { if let Some(index) = VeinId::from_index(vein as usize) { self.gains[index.index()] = gain.clamp(MIN_GAIN, MAX_GAIN); } } AudioCommand::SetMaterial(material) => { self.material = Some(material); } } } fn free_voice(&self) -> usize { self.voices .iter() .position(|voice| !voice.active) .unwrap_or_else(|| { self.voices .iter() .enumerate() .max_by(|a, b| a.1.t.total_cmp(&b.1.t)) .map(|(index, _)| index) .unwrap_or(0) }) } fn trigger(&mut self, vein: VeinId, velocity: f32) { if velocity <= 0.0 { return; } let index = self.free_voice(); self.rng = self.rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); self.voices[index].trigger(vein, velocity, self.rng); } fn trigger_sample( &mut self, core_index: usize, face_index: u8, vein_index: usize, velocity: f32, ) { if velocity <= 0.0 { return; } let Some(material) = self.material.as_ref().cloned() else { return; }; let Some(core) = material.cores.get(core_index) else { return; }; let Some(face) = core.faces.iter().find(|face| face.index == face_index) else { return; }; let start = face.start as usize; let end = face.end as usize; if start >= end || end > core.samples.len() { return; } let Some(vein) = VeinId::from_index(vein_index) else { return; }; let rate = core.sample_rate as f64 / self.sample_rate as f64; let index = self.free_voice(); self.voices[index].trigger_sample(vein, velocity, core_index, start, end, rate); } fn trigger_step(&mut self) { let step = self.step; let (plan, count) = { let material = self.material.as_ref().cloned(); let pattern = material .as_ref() .map(|material| &material.pattern) .unwrap_or(&self.fallback); let mut plan: [StepTrigger; 8] = [(0, 0, None); 8]; let mut count = 0; for row in &pattern.rows { if count >= plan.len() { break; } let velocity = row.velocities.get(step).copied().unwrap_or(0); if velocity == 0 { continue; } let face = row.face.as_ref().and_then(|reference| { material.as_ref().and_then(|material| { material .cores .iter() .position(|core| core.id == reference.core) .map(|core_index| (core_index, reference.face)) }) }); plan[count] = (row.vein.index(), velocity, face); count += 1; } (plan, count) }; for (vein_index, velocity, face) in plan.into_iter().take(count) { if let Some((core_index, face_index)) = face { self.trigger_sample(core_index, face_index, vein_index, velocity as f32 / 127.0); } else if let Some(vein) = VeinId::from_index(vein_index) { self.trigger(vein, velocity as f32 / 127.0); } } self.bar = step as f32 / STEPS_PER_BAR as f32 + 1.0; self.step = (step + 1) % LOOP_STEPS; } fn render(&mut self, data: &mut [T], levels: &Levels) where T: SizedSample + FromSample, { while let Ok(command) = self.rx.pop() { self.handle(command); } let material = self.material.as_ref().cloned(); for frame in data.chunks_mut(self.channels) { if self.playing { if self.pending <= 0.0 { self.trigger_step(); self.pending += self.step_samples(); } self.pending -= 1.0; } let mut left = 0.0f32; let mut right = 0.0f32; let mut sums = [0.0f32; VEIN_COUNT]; for voice in &mut self.voices { if !voice.active { continue; } let sample = voice.next(self.sample_rate, material.as_deref()); let index = voice.vein; let amplitude = sample * self.gains[index]; left += amplitude * voice.gain_l; right += amplitude * voice.gain_r; sums[index] += amplitude.abs(); } let peak = (left.abs() + right.abs()) * 0.5; self.master_env += (peak - self.master_env) * 0.0015; for (env, sum) in self.vein_env.iter_mut().zip(sums.iter()) { *env += (*sum - *env) * 0.002; } let out_l = (left * 0.9).tanh() * 0.8; let out_r = (right * 0.9).tanh() * 0.8; for (channel, sample) in frame.iter_mut().enumerate() { let value = match (self.channels, channel) { (1, _) => (out_l + out_r) * 0.5, (_, 0) => out_l, (_, 1) => out_r, _ => (out_l + out_r) * 0.5, }; *sample = T::from_sample(value); } } levels.store(&MeterFrame { bar: self.bar, master: self.master_env, veins: self.vein_env, }); } } /// The send side of the engine. Cheap, lock-free for the audio thread and /// safe to share with the session and the WebSocket daemon. pub struct Control { tx: Mutex>, levels: Arc, info: AudioInfo, } impl EngineControl for Control { fn play(&self) { self.send(AudioCommand::Play); } fn stop(&self) { self.send(AudioCommand::Stop); } fn strike(&self, vein: VeinId, velocity: f32) { self.send(AudioCommand::Strike { vein: vein.index() as u8, velocity, }); } fn strike_face(&self, core: &str, face: u8, velocity: f32) { self.send(AudioCommand::StrikeFace { core: core.to_string(), face, velocity, }); } fn set_tempo(&self, bpm: f32) { self.send(AudioCommand::SetTempo(bpm)); } fn set_gain(&self, vein: VeinId, gain: f32) { self.send(AudioCommand::SetGain { vein: vein.index() as u8, gain, }); } fn set_material(&self, material: Arc) { self.send(AudioCommand::SetMaterial(material)); } fn info(&self) -> AudioInfo { self.info.clone() } fn meter(&self) -> MeterFrame { self.levels.load() } } impl Control { fn send(&self, command: AudioCommand) { if let Ok(mut tx) = self.tx.lock() { let _ = tx.push(command); } } } #[derive(Debug, Error)] pub enum EngineError { #[error("no default audio output device")] NoDevice, #[error("audio backend: {0}")] Backend(#[from] cpal::Error), #[error("unsupported sample format: {0:?}")] UnsupportedFormat(SampleFormat), } struct Started { stream: Stream, control: Arc, } /// Keeps the cpal stream alive. The control handle can be shared freely; the /// stream itself must stay on the thread that created it. pub struct AudioEngine { _stream: Stream, control: Arc, } impl AudioEngine { pub fn start(pattern: &Pattern) -> Result { let started = match Self::try_start(pattern, BufferSize::Fixed(256)) { Ok(started) => started, Err(EngineError::Backend(_)) => Self::try_start(pattern, BufferSize::Default)?, Err(error) => return Err(error), }; Ok(Self { _stream: started.stream, control: started.control, }) } pub fn control(&self) -> Arc { self.control.clone() } pub fn info(&self) -> AudioInfo { self.control.info.clone() } fn try_start(pattern: &Pattern, buffer: BufferSize) -> Result { let host = cpal::default_host(); let device = host.default_output_device().ok_or(EngineError::NoDevice)?; let supported = device.default_output_config()?; let format = supported.sample_format(); let mut config: StreamConfig = supported.into(); config.buffer_size = buffer; let sample_rate = config.sample_rate; let channels = config.channels; let device_name = device.to_string(); let (producer, consumer) = RingBuffer::new(256); let levels = Arc::new(Levels::new()); let state = AudioState::new(sample_rate, channels, pattern, consumer); let stream = match format { SampleFormat::F32 => build_stream::(&device, config, state, levels.clone())?, SampleFormat::I16 => build_stream::(&device, config, state, levels.clone())?, other => return Err(EngineError::UnsupportedFormat(other)), }; let info = AudioInfo { running: true, device: device_name, sample_rate, buffer_frames: match buffer { BufferSize::Fixed(frames) => frames, BufferSize::Default => 0, }, channels, }; let control = Arc::new(Control { tx: Mutex::new(producer), levels, info, }); Ok(Started { stream, control }) } } fn build_stream( device: &cpal::Device, config: StreamConfig, mut state: AudioState, levels: Arc, ) -> Result where T: SizedSample + FromSample + 'static, { let stream = device.build_output_stream( config, move |data: &mut [T], _: &cpal::OutputCallbackInfo| { state.render(data, &levels); }, |error| eprintln!("zhila audio: {error}"), None, )?; stream.play()?; Ok(stream) } /// A silent engine for headless protocol tests and machines without an output /// device. It reports itself as offline so the surface never pretends to play. pub struct NullEngine { info: AudioInfo, } impl Default for NullEngine { fn default() -> Self { Self::new() } } impl NullEngine { pub fn new() -> Self { Self { info: AudioInfo::offline(), } } } impl EngineControl for NullEngine { fn play(&self) {} fn stop(&self) {} fn strike(&self, _vein: VeinId, _velocity: f32) {} fn strike_face(&self, _core: &str, _face: u8, _velocity: f32) {} fn set_tempo(&self, _bpm: f32) {} fn set_gain(&self, _vein: VeinId, _gain: f32) {} fn set_material(&self, _material: Arc) {} fn info(&self) -> AudioInfo { self.info.clone() } fn meter(&self) -> MeterFrame { MeterFrame::silent() } }