P2b: offline WAV render of the arrangement

This commit is contained in:
Emil
2026-11-01 02:19:25 +03:00
parent c85e3cc723
commit 0df07577a1
17 changed files with 345 additions and 10 deletions
+1
View File
@@ -26,3 +26,4 @@ anyhow.workspace = true
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
hound = "3"
+115 -3
View File
@@ -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