P0: Rust engine, wire daemon and audio-reactive descent surface

This commit is contained in:
Emil
2026-11-01 00:36:04 +03:00
parent cd967cced4
commit db4368efae
68 changed files with 19507 additions and 3 deletions
+538
View File
@@ -0,0 +1,538 @@
//! 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;
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;
#[derive(Debug, Clone, Copy)]
enum AudioCommand {
Play,
Stop,
Strike { vein: u8, velocity: f32 },
SetTempo(f32),
SetGain { vein: u8, gain: f32 },
}
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 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,
}
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,
}
}
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;
let (freq, attack, decay, pan) = match vein {
VeinId::Bass => (55.0, 0.004, 3.2, 0.0),
VeinId::Crack => (0.0, 0.001, 38.0, 0.35),
VeinId::Voice => (220.0, 0.09, 1.1, -0.25),
VeinId::Noise => (0.0, 0.004, 5.0, 0.55),
VeinId::Silt => (1318.5, 0.002, 14.0, -0.45),
};
self.freq = freq;
self.attack = attack;
self.decay = decay;
let angle = (pan + 1.0) * FRAC_PI_4;
self.gain_l = angle.cos();
self.gain_r = angle.sin();
}
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) -> f32 {
self.t += 1.0 / sample_rate;
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
}
}
struct AudioState {
rx: Consumer<AudioCommand>,
sample_rate: f32,
channels: usize,
bpm: f32,
playing: bool,
step: usize,
pending: f64,
pattern: [[u8; LOOP_STEPS]; VEIN_COUNT],
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<AudioCommand>) -> Self {
let mut grid = [[0u8; LOOP_STEPS]; VEIN_COUNT];
for row in &pattern.rows {
let index = row.vein.index();
for (step, velocity) in row.velocities.iter().take(LOOP_STEPS).enumerate() {
grid[index][step] = *velocity;
}
}
Self {
rx,
sample_rate: sample_rate as f32,
channels: channels as usize,
bpm: 128.0,
playing: false,
step: 0,
pending: 0.0,
pattern: grid,
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::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);
}
}
}
}
fn trigger(&mut self, vein: VeinId, velocity: f32) {
if velocity <= 0.0 {
return;
}
let index = 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)
});
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_step(&mut self) {
let step = self.step;
for vein in 0..VEIN_COUNT {
let velocity = self.pattern[vein][step];
if velocity > 0
&& let Some(vein) = VeinId::from_index(vein)
{
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<T>(&mut self, data: &mut [T], levels: &Levels)
where
T: SizedSample + FromSample<f32>,
{
while let Ok(command) = self.rx.pop() {
self.handle(command);
}
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);
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<Producer<AudioCommand>>,
levels: Arc<Levels>,
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 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 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<Control>,
}
/// 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<Control>,
}
impl AudioEngine {
pub fn start(pattern: &Pattern) -> Result<Self, EngineError> {
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<Control> {
self.control.clone()
}
pub fn info(&self) -> AudioInfo {
self.control.info.clone()
}
fn try_start(pattern: &Pattern, buffer: BufferSize) -> Result<Started, EngineError> {
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::<f32>(&device, config, state, levels.clone())?,
SampleFormat::I16 => build_stream::<i16>(&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<T>(
device: &cpal::Device,
config: StreamConfig,
mut state: AudioState,
levels: Arc<Levels>,
) -> Result<Stream, EngineError>
where
T: SizedSample + FromSample<f32> + '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 set_tempo(&self, _bpm: f32) {}
fn set_gain(&self, _vein: VeinId, _gain: f32) {}
fn info(&self) -> AudioInfo {
self.info.clone()
}
fn meter(&self) -> MeterFrame {
MeterFrame::silent()
}
}