P2b: offline WAV render of the arrangement
This commit is contained in:
@@ -587,6 +587,12 @@ impl Session {
|
||||
Command::SelectSection { index } => self.select_section(index, actor),
|
||||
Command::ClearSection { index } => self.clear_section(index, actor),
|
||||
Command::CopySection { from, to } => self.copy_section(from, to, actor),
|
||||
Command::Render => Ok((
|
||||
CommandResult::State {
|
||||
state: self.snapshot(),
|
||||
},
|
||||
Vec::new(),
|
||||
)),
|
||||
Command::Undo => {
|
||||
let Some(edit) = self.history.pop() else {
|
||||
return Ok((
|
||||
|
||||
@@ -26,3 +26,4 @@ anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
hound = "3"
|
||||
|
||||
@@ -17,11 +17,12 @@ use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
|
||||
use tokio_tungstenite::{WebSocketStream, accept_hdr_async};
|
||||
use tracing::{info, warn};
|
||||
use zhila_core::{CoreError, Engine, Session, default_pattern};
|
||||
use zhila_engine::{AudioEngine, NullEngine};
|
||||
use zhila_core::{CoreError, Engine, MaterialSnapshot, Session, default_pattern};
|
||||
use zhila_engine::{AudioEngine, NullEngine, RenderRequest, render_arrangement};
|
||||
use zhila_project::{ProjectSnapshot, ProjectStore};
|
||||
use zhila_protocol::{
|
||||
Actor, ClientMessage, Command, CommandResult, ErrorCode, PROTOCOL_VERSION, Seal, ServerMessage,
|
||||
Actor, ClientMessage, Command, CommandResult, ErrorCode, Event, PROTOCOL_VERSION, Pattern,
|
||||
Seal, ServerMessage, VEIN_COUNT,
|
||||
};
|
||||
|
||||
const HELLO_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
@@ -194,6 +195,60 @@ fn print_banner(
|
||||
println!();
|
||||
}
|
||||
|
||||
struct RenderInput {
|
||||
sections: Vec<Pattern>,
|
||||
material: MaterialSnapshot,
|
||||
gains: [f32; VEIN_COUNT],
|
||||
bpm: f32,
|
||||
project: String,
|
||||
}
|
||||
|
||||
fn render_to_wav(input: RenderInput, root: &std::path::Path) -> anyhow::Result<(String, f32)> {
|
||||
let samples = render_arrangement(RenderRequest {
|
||||
sections: input.sections,
|
||||
material: Some(input.material),
|
||||
gains: input.gains,
|
||||
bpm: input.bpm,
|
||||
sample_rate: 48_000,
|
||||
tail_sec: 1.5,
|
||||
});
|
||||
let dir = root.join("renders");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let name = format!("{}-{}.wav", sanitize_name(&input.project), now_ms());
|
||||
let path = dir.join(&name);
|
||||
let spec = hound::WavSpec {
|
||||
channels: 2,
|
||||
sample_rate: 48_000,
|
||||
bits_per_sample: 32,
|
||||
sample_format: hound::SampleFormat::Float,
|
||||
};
|
||||
let mut writer = hound::WavWriter::create(&path, spec)?;
|
||||
for sample in &samples {
|
||||
writer.write_sample(*sample)?;
|
||||
}
|
||||
writer.finalize()?;
|
||||
let duration = samples.len() as f32 / 2.0 / 48_000.0;
|
||||
Ok((path.display().to_string(), duration))
|
||||
}
|
||||
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
let cleaned: String = name
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
|
||||
character
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if cleaned.is_empty() {
|
||||
"zhila".into()
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_now(shared: &Shared) {
|
||||
let session = shared.session.lock().await;
|
||||
let state = session.snapshot();
|
||||
@@ -476,6 +531,63 @@ async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
ClientMessage::Command { id, command } => {
|
||||
if matches!(&command, Command::Render) {
|
||||
let input = {
|
||||
let session = shared.session.lock().await;
|
||||
RenderInput {
|
||||
sections: session.sections().to_vec(),
|
||||
material: session.material_snapshot(),
|
||||
gains: session.gains(),
|
||||
bpm: session.bpm(),
|
||||
project: session.project_name().to_string(),
|
||||
}
|
||||
};
|
||||
let root = shared
|
||||
.store
|
||||
.lock()
|
||||
.expect("project store lock poisoned")
|
||||
.root()
|
||||
.to_path_buf();
|
||||
let rendered =
|
||||
tokio::task::spawn_blocking(move || render_to_wav(input, &root)).await;
|
||||
match rendered {
|
||||
Ok(Ok((path, duration_sec))) => {
|
||||
let state = shared.session.lock().await.snapshot();
|
||||
let revision = state.revision;
|
||||
let _ = direct_tx.send(Outbound::Text(
|
||||
serde_json::to_string(&ServerMessage::Result {
|
||||
id,
|
||||
result: CommandResult::State { state },
|
||||
})
|
||||
.expect("render result serializes"),
|
||||
));
|
||||
let message = ServerMessage::Event {
|
||||
revision,
|
||||
event: Event::Rendered { path, duration_sec },
|
||||
};
|
||||
let _ = shared
|
||||
.out
|
||||
.send(Outbound::Text(serde_json::to_string(&message).unwrap()));
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
warn!("render failed: {error:#}");
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::Internal,
|
||||
"the render failed",
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("render task failed: {error}");
|
||||
let _ = direct_tx.send(error_message(
|
||||
Some(id),
|
||||
ErrorCode::Internal,
|
||||
"the render task failed",
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let should_save = !matches!(
|
||||
&command,
|
||||
Command::Play
|
||||
|
||||
@@ -20,6 +20,46 @@ use zhila_protocol::{
|
||||
|
||||
const VOICES: usize = 32;
|
||||
|
||||
/// Input for the offline renderer. The same voices, gains and arrangement as
|
||||
/// the real-time engine; no cpal device is opened.
|
||||
pub struct RenderRequest {
|
||||
pub sections: Vec<Pattern>,
|
||||
pub material: Option<MaterialSnapshot>,
|
||||
pub gains: [f32; VEIN_COUNT],
|
||||
pub bpm: f32,
|
||||
pub sample_rate: u32,
|
||||
pub tail_sec: f32,
|
||||
}
|
||||
|
||||
/// Render the full arrangement to interleaved stereo f32. The result includes
|
||||
/// the tail so the last voices decay instead of being cut.
|
||||
pub fn render_arrangement(request: RenderRequest) -> Vec<f32> {
|
||||
let sections = request.sections;
|
||||
let sample_rate = request.sample_rate.max(8_000);
|
||||
let fallback = sections
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(zhila_core::default_pattern);
|
||||
let (_, consumer) = RingBuffer::new(16);
|
||||
let mut state = AudioState::new(sample_rate, 2, &fallback, consumer);
|
||||
state.bpm = request.bpm.clamp(MIN_BPM, MAX_BPM);
|
||||
state.gains = request.gains;
|
||||
state.material = request.material.map(Arc::new);
|
||||
state.playing = true;
|
||||
state.step = 0;
|
||||
state.pending = 0.0;
|
||||
state.bar = 1.0;
|
||||
let steps = sections.len().max(1) * LOOP_STEPS;
|
||||
let step_samples = sample_rate as f64 * 60.0 / state.bpm as f64 / 2.0;
|
||||
let frames = (steps as f64 * step_samples
|
||||
+ request.tail_sec.max(0.0) as f64 * sample_rate as f64)
|
||||
.round() as usize;
|
||||
let mut buffer = vec![0.0f32; frames * 2];
|
||||
let levels = Levels::new();
|
||||
state.render(&mut buffer, &levels);
|
||||
buffer
|
||||
}
|
||||
|
||||
/// (vein index, velocity, optional (core index, face index)) for one step.
|
||||
type StepTrigger = (usize, u8, Option<(usize, u8)>);
|
||||
|
||||
@@ -744,3 +784,29 @@ impl EngineControl for NullEngine {
|
||||
MeterFrame::silent()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn renders_a_non_silent_arrangement() {
|
||||
let sections = vec![zhila_core::default_pattern(); 4];
|
||||
let buffer = render_arrangement(RenderRequest {
|
||||
sections,
|
||||
material: None,
|
||||
gains: zhila_core::INITIAL_GAINS,
|
||||
bpm: 128.0,
|
||||
sample_rate: 48_000,
|
||||
tail_sec: 0.25,
|
||||
});
|
||||
let frames = buffer.len() / 2;
|
||||
let expected =
|
||||
(64.0f64 * (48_000.0 * 60.0 / 128.0 / 2.0) + 0.25 * 48_000.0).round() as usize;
|
||||
assert!((frames as i64 - expected as i64).abs() < 4);
|
||||
let peak = buffer
|
||||
.iter()
|
||||
.fold(0.0f32, |peak, sample| peak.max(sample.abs()));
|
||||
assert!(peak > 0.01, "render was silent: {peak}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ pub enum Command {
|
||||
from: u8,
|
||||
to: u8,
|
||||
},
|
||||
Render,
|
||||
Undo,
|
||||
Redo,
|
||||
/// Journal-only: a core was imported through the binary path.
|
||||
@@ -277,6 +278,10 @@ pub enum Event {
|
||||
sections: Vec<Pattern>,
|
||||
active: u8,
|
||||
},
|
||||
Rendered {
|
||||
path: String,
|
||||
duration_sec: f32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user