diff --git a/Cargo.lock b/Cargo.lock index 22fa63e..64758a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1787,6 +1787,7 @@ dependencies = [ "anyhow", "clap", "futures-util", + "hound", "serde_json", "tokio", "tokio-tungstenite", diff --git a/README.md b/README.md index 36f4f5d..d15f89c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Sound is terrain. A sampling instrument where material is cut in a quarry, laid as stones in a nave, and played by a hand or an agent through the same verbs. -This repository is **P2 in progress**: the engine, the wire, the descent, the quarry, a persistent project, recording, manual faces, undo across restarts, a native host — and an **8-bar arrangement of four sections**. A found or recorded sound is cut into sixteen faces, laid on a vein, arranged across A–D, and heard as an eight-bar loop; the render and the agent are the remaining P2 slices. The visual demo in [`reference/demo`](reference/demo) remains a design reference, not the codebase. The roadmap is in [`docs/PLAN.md`](docs/PLAN.md). +This repository is **P2 in progress**: the engine, the wire, the descent, the quarry, a persistent project, recording, manual faces, undo across restarts, a native host, an **8-bar arrangement of four sections**, and an **offline WAV render**. A found or recorded sound is cut into sixteen faces, laid on a vein, arranged across A–D, heard as an eight-bar loop and rendered to a 16.5-second WAV; the agent is the remaining P2 slice. The visual demo in [`reference/demo`](reference/demo) remains a design reference, not the codebase. The roadmap is in [`docs/PLAN.md`](docs/PLAN.md). ![The quarry](preview-p1-quarry.png) @@ -18,6 +18,8 @@ This repository is **P2 in progress**: the engine, the wire, the descent, the qu ![P2 · eight bars, four sections](preview-p2-arrangement.png) +![P2 · an offline render](preview-p2-render.png) + ## What is real - **Rust engine.** `cpal` output at 48 kHz, five veins, a sixteen-step phrase, a fixed voice pool, per-vein levels and gains, a bar clock. Procedural voices for empty pattern rows; real sample voices for rows with a face. No Web Audio. @@ -32,6 +34,7 @@ This repository is **P2 in progress**: the engine, the wire, the descent, the qu - **Surface.** A Svelte 5 descent whose terrain, eye, city, road, stones and playhead react to the five levels and the master; a real wire log; engine facts; cilia; a master signal trace; vein gains; transport; keyboard. - **Native host.** The Tauri app starts the same host inside its own process: engine, session, SQLite project, assets and loopback wire. The built window is self-contained; the browser path uses the standalone daemon. One process owns `127.0.0.1:4317` at a time. - **Arrangement.** Four 2-bar sections (A–D) make an 8-bar loop. The engine plays the sections in order; the descent draws all eight bars with section seams and letters; the quarry edits the active section, selects, copies and clears. The sections, the active section and their edits are persisted and undoable; the pattern rows and faces stay per section. +- **Render.** The `render` button (or the `render` command) renders the full 8-bar arrangement offline with the same voices, gains, sample faces and master as the live engine, then writes a 32-bit float stereo WAV to `/renders/`. The `rendered` event carries the path and duration; no device is opened for the render. - **Verification.** Rust unit tests, a live wire test, a material import test, a browser quarry test and the P0 regression smoke, plus `svelte-check` and a production build. The Tauri 2 app is **built and running with its own engine**: the native window loads the embedded surface and the host starts the engine, session, project and wire in the app process. It is built with Tauri 2.12.5 / CLI 2.12.4 against WebKitGTK 2.54.2, GTK 3.24.52 and dbus 1.16.2. The binary is `app/src-tauri/target/release/zhila-app` (14 MB); installers are a later step. @@ -105,6 +108,8 @@ npm --prefix ui run project-smoke # daemon + Vite: project, undo, import, sc npm --prefix ui run record-smoke # daemon + Vite: fake microphone take, faces, seal npm --prefix ui run faces-smoke # daemon + Vite: drag a seam, trim, save, screenshot npm --prefix ui run arrangement-smoke # daemon + Vite: sections, edit B, 8-bar descent +node scripts/render-smoke.mjs # daemon running: render, parse the WAV +npm --prefix ui run render-ui-smoke # daemon + Vite: render button, wire, screenshot cd app && ./node_modules/.bin/tauri build --no-bundle # native app with the embedded host npm --prefix ui run restored-smoke # after restarting the daemon on the saved project ``` @@ -114,7 +119,8 @@ npm --prefix ui run restored-smoke # after restarting the daemon on the saved ## P2 limits (in progress, not hidden) - The arrangement is exactly four 2-bar sections; no section length, reorder, insert or per-section mute yet. -- Render and the agent are the next P2 slices; they are not in this commit. +- Render is 48 kHz stereo 32-bit float with a 1.5 s tail; no stems, normalisation or per-section export. +- The agent is the remaining P2 slice. ## P1 limits (kept honest) @@ -129,4 +135,4 @@ npm --prefix ui run restored-smoke # after restarting the daemon on the saved - No agent/MCP in this phase. - The Tauri shell now builds and runs; the engine is still the separate daemon (embedding is next). -P2 is the arrangement, the offline WAV render and the in-app agent. P2a (the arrangement) is in this tree; P2b and P2c follow. The plan is in [`docs/PLAN.md`](docs/PLAN.md); the phase notes are in [`docs/P2.md`](docs/P2.md). The plan is in [`docs/PLAN.md`](docs/PLAN.md); the phase notes are in [`docs/P1.md`](docs/P1.md). +P2 is the arrangement, the offline WAV render and the in-app agent. P2a (the arrangement) and P2b (the render) are in this tree; P2c follows. The plan is in [`docs/PLAN.md`](docs/PLAN.md); the phase notes are in [`docs/P2.md`](docs/P2.md). The plan is in [`docs/PLAN.md`](docs/PLAN.md); the phase notes are in [`docs/P1.md`](docs/P1.md). diff --git a/crates/zhila-core/src/lib.rs b/crates/zhila-core/src/lib.rs index 5eaab2c..dce25cf 100644 --- a/crates/zhila-core/src/lib.rs +++ b/crates/zhila-core/src/lib.rs @@ -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(( diff --git a/crates/zhila-daemon/Cargo.toml b/crates/zhila-daemon/Cargo.toml index 2d36d03..b3309ef 100644 --- a/crates/zhila-daemon/Cargo.toml +++ b/crates/zhila-daemon/Cargo.toml @@ -26,3 +26,4 @@ anyhow.workspace = true clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +hound = "3" diff --git a/crates/zhila-daemon/src/lib.rs b/crates/zhila-daemon/src/lib.rs index a3147f8..18627fc 100644 --- a/crates/zhila-daemon/src/lib.rs +++ b/crates/zhila-daemon/src/lib.rs @@ -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, + 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 diff --git a/crates/zhila-engine/src/lib.rs b/crates/zhila-engine/src/lib.rs index 772cfe4..c56c1b1 100644 --- a/crates/zhila-engine/src/lib.rs +++ b/crates/zhila-engine/src/lib.rs @@ -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, + pub material: Option, + 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 { + 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}"); + } +} diff --git a/crates/zhila-protocol/src/lib.rs b/crates/zhila-protocol/src/lib.rs index 5a0b0c0..183486d 100644 --- a/crates/zhila-protocol/src/lib.rs +++ b/crates/zhila-protocol/src/lib.rs @@ -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, active: u8, }, + Rendered { + path: String, + duration_sec: f32, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/docs/P2.md b/docs/P2.md index beba27c..5f8d394 100644 --- a/docs/P2.md +++ b/docs/P2.md @@ -18,9 +18,15 @@ Verification: Limits: exactly four sections of two bars; no insert/reorder/length; select is not undoable; no per-section mute. -## P2b · render (next) +## P2b · render -Offline render of the 8-bar arrangement to a WAV inside the project, with a command and a surface control. +`zhila_engine::render_arrangement(RenderRequest)` drives the same `AudioState` offline: no cpal device, the same voices, sample faces, gains, master and the 8-bar sections, plus a 1.5 s tail. `Command::render` is intercepted by the host, rendered in `spawn_blocking`, written with `hound` as 48 kHz stereo 32-bit float to `/renders/-.wav`, and announced with `Event::rendered { path, duration_sec }`; the command result is the unchanged state. Render is not a project edit and does not push history. + +Surface: `render` in the top rail; the wire logs the send and the `rendered` path; the button's title keeps the last path. + +Verification: `cargo test -p zhila-engine` (non-silent 16.5 s buffer, correct frame count); `node scripts/render-smoke.mjs` (status 16.5 s, peak 0.442, 6.3 MB, float 48 kHz stereo, event duration matches); `npm --prefix ui run render-ui-smoke` (button → wire path, screenshot `preview-p2-render.png`). + +Limits: WAV only, stereo float, fixed 48 kHz and 1.5 s tail; no stems, normalisation or section ranges. ## P2c · agent (next) diff --git a/docs/PLAN.md b/docs/PLAN.md index d967ea5..9b61679 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,7 +1,7 @@ # ZHILA — план проекта Черновик v1.1 · 1 ноября 2026 -Статус: **P2 в работе** (P0, P1a–P1f, P2a; см. `P0.md`, `P1.md`, `P2.md`). Демо `reference/demo/` — референс, не кодовая база. Осталось: рендер WAV и агент. +Статус: **P2 в работе** (P0, P1a–P1f, P2a, P2b; см. `P0.md`, `P1.md`, `P2.md`). Демо `reference/demo/` — референс, не кодовая база. Осталось: агент. **Зафиксировано 1 ноября:** descent — основной вид; камень = паттерн; климат — на жилу (концепт погоды остаётся); платформа v1 — Linux; UI — Svelte 5. Журнал — §9. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 03db42e..2731206 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -172,3 +172,7 @@ The Tauri app starts the same host in-process, so the wire contract is unchanged ## P2a additions `State` adds `arrangement: Pattern[]` (four sections in this phase) and `active_section: u8`; `State.pattern` is the active section. Commands `select_section { index }`, `clear_section { index }`, `copy_section { from, to }`; event `arrangement { sections, active }`. Pattern step/face commands edit the active section. The project row stores `arrangement` and `active_section`; old projects with only `pattern` are migrated to four copies of it. + +## P2b additions + +`Command::render` (no arguments). The host renders the current arrangement to a WAV in the project, answers with the unchanged state, and broadcasts `rendered { path, duration_sec }`. The render is not journaled and does not push undo. The file is 48 kHz stereo 32-bit float. diff --git a/preview-p2-render.png b/preview-p2-render.png new file mode 100644 index 0000000..b9fa2d3 Binary files /dev/null and b/preview-p2-render.png differ diff --git a/scripts/render-smoke.mjs b/scripts/render-smoke.mjs new file mode 100644 index 0000000..4335e52 --- /dev/null +++ b/scripts/render-smoke.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Render the current arrangement through the daemon and verify the WAV. +import { readFileSync, existsSync } from 'node:fs'; + +const PORT = 4317; +const ws = new WebSocket(`ws://127.0.0.1:${PORT}`); +const pending = new Map(); +let rendered = null; +let resolveRendered; +const renderedReady = new Promise((resolve) => { resolveRendered = resolve; }); + +ws.onopen = () => ws.send(JSON.stringify({ type: 'hello', protocol: 1, token: 'dev', client: 'render-smoke' })); +ws.onmessage = (message) => { + if (typeof message.data !== 'string') return; + const parsed = JSON.parse(message.data); + if (parsed.type === 'snapshot') { + ws.send(JSON.stringify({ type: 'command', id: 1, command: { type: 'render' } })); + } else if (parsed.type === 'result' && parsed.id === 1) { + pending.set(1, parsed.result); + } else if (parsed.type === 'event' && parsed.event?.kind === 'rendered') { + rendered = parsed.event; + resolveRendered(); + } +}; + +await renderedReady; +if (!rendered) throw new Error('no rendered event'); +const path = rendered.path; +if (!existsSync(path)) throw new Error(`render file missing: ${path}`); +const file = readFileSync(path); +if (file.toString('ascii', 0, 4) !== 'RIFF' || file.toString('ascii', 8, 12) !== 'WAVE') throw new Error('not a RIFF/WAVE file'); +let offset = 12; +let fmt = null; +let data = null; +while (offset + 8 <= file.length) { + const id = file.toString('ascii', offset, offset + 4); + const size = file.readUInt32LE(offset + 4); + if (id === 'fmt ') fmt = { offset: offset + 8, size }; + if (id === 'data') { + data = { offset: offset + 8, size: Math.min(size, file.length - offset - 8) }; + break; + } + offset += 8 + size + (size % 2); +} +if (!fmt || !data) throw new Error('missing fmt or data chunk'); +let format = file.readUInt16LE(fmt.offset); +const channels = file.readUInt16LE(fmt.offset + 2); +const rate = file.readUInt32LE(fmt.offset + 4); +const bits = file.readUInt16LE(fmt.offset + 14); +if (format === 0xfffe) format = file.readUInt16LE(fmt.offset + 24); +if (format !== 3) throw new Error(`expected float WAV, got format ${format}`); +if (channels !== 2 || rate !== 48000 || bits !== 32) throw new Error(`unexpected spec ${channels}/${rate}/${bits}`); +const bytes = file.subarray(data.offset, data.offset + data.size); +let peak = 0; +for (let cursor = 0; cursor + 4 <= bytes.length; cursor += 4) { + const sample = bytes.readFloatLE(cursor); + peak = Math.max(peak, Math.abs(sample)); +} +const duration = data.size / (channels * 4) / rate; +console.log(JSON.stringify({ + path, + bytes: file.length, + format, channels, rate, bits, + durationSec: Number(duration.toFixed(3)), + eventDurationSec: rendered.duration_sec, + peak: Number(peak.toFixed(3)), + renderedState: pending.get(1)?.state?.revision +}, null, 2)); +if (peak < 0.01) throw new Error('render is silent'); +if (duration < 15) throw new Error('render is shorter than the arrangement'); +if (Math.abs(duration - rendered.duration_sec) > 0.05) throw new Error('duration mismatch'); +ws.close(); +console.log('render smoke passed'); diff --git a/ui/package.json b/ui/package.json index 25379c2..d43d600 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,6 +14,7 @@ "record-smoke": "node scripts/record-smoke.mjs", "faces-smoke": "node scripts/faces-smoke.mjs", "arrangement-smoke": "node scripts/arrangement-smoke.mjs", + "render-ui-smoke": "node scripts/render-ui-smoke.mjs", "restored-smoke": "node scripts/restored-smoke.mjs" }, "devDependencies": { diff --git a/ui/scripts/render-ui-smoke.mjs b/ui/scripts/render-ui-smoke.mjs new file mode 100644 index 0000000..4679a2d --- /dev/null +++ b/ui/scripts/render-ui-smoke.mjs @@ -0,0 +1,30 @@ +import { chromium } from 'playwright-core'; +const chrome = process.env.CHROME_PATH ?? '/home/emil/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome'; +const browser = await chromium.launch({ executablePath: chrome, args: ['--no-sandbox'] }); +const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 }); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); }); +await page.goto('http://127.0.0.1:5173/', { waitUntil: 'load' }); +await page.waitForTimeout(2200); +await page.locator('.render-button').click(); +await page.waitForFunction( + () => [...document.querySelectorAll('.wire-label')].some((el) => el.textContent?.trim() === 'render'), + null, + { timeout: 30000 } +); +await page.waitForTimeout(500); +await page.screenshot({ path: '../preview-p2-render.png' }); +const report = await page.evaluate(() => ({ + wire: [...document.querySelectorAll('.wire-entry')].slice(-4).map((el) => el.textContent?.replace(/\s+/g, ' ').trim()), + title: document.querySelector('.render-button')?.getAttribute('title'), + button: document.querySelector('.render-button')?.textContent?.trim() +})); +console.log(JSON.stringify({ report, errors }, null, 2)); +const failures = []; +if (!report.title?.startsWith('last render:')) failures.push('render path is not on the button'); +if (!report.wire.some((line) => line?.includes('16.5 s'))) failures.push('render event is not on the wire'); +if (errors.length) failures.push('console errors'); +await browser.close(); +if (failures.length) { console.error('RENDER UI SMOKE FAILED: ' + failures.join('; ')); process.exit(1); } +console.log('render UI smoke passed'); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index cd0f171..c42d8d9 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -38,6 +38,8 @@ let selectedFace: number = $state(-1); let selectedRow: number = $state(0); let importing: boolean = $state(false); + let rendering: boolean = $state(false); + let lastRender: string | null = $state(null); let bridge: Bridge | null = null; let entryId = 0; let snapshotLogged = false; @@ -109,6 +111,9 @@ project.arrangement = event.sections; project.active_section = event.active; project.pattern = event.sections[event.active] ?? project.pattern; + } else if (event.kind === 'rendered') { + lastRender = event.path; + addEntry('recv', 'render', `${event.duration_sec.toFixed(1)} s · ${event.path}`); } } @@ -216,6 +221,16 @@ void run({ type: 'copy_section', from, to }); } + async function renderProject() { + if (!bridge || rendering) return; + rendering = true; + try { + await run({ type: 'render' }); + } finally { + rendering = false; + } + } + function undo() { if (project?.can_undo) void run({ type: 'undo' }); } @@ -322,6 +337,12 @@
+
diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts index 77480b5..c7d001d 100644 --- a/ui/src/lib/format.ts +++ b/ui/src/lib/format.ts @@ -37,6 +37,7 @@ export function commandLabel(command: Command): string { case 'select_section': return `section ${String.fromCharCode(65 + command.index)}`; case 'clear_section': return `clear section ${String.fromCharCode(65 + command.index)}`; case 'copy_section': return `copy ${String.fromCharCode(65 + command.from)} → ${String.fromCharCode(65 + command.to)}`; + case 'render': return 'render wav'; case 'undo': return 'undo'; case 'redo': return 'redo'; case 'get_snapshot': return 'snapshot'; diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 6b9ab98..ccdb238 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -84,7 +84,8 @@ export type Event = | { kind: 'face_struck'; core: string; face: number; velocity: number } | { kind: 'history'; can_undo: boolean; can_redo: boolean } | { kind: 'core_faces'; core: CoreSummary } - | { kind: 'arrangement'; sections: Pattern[]; active: number }; + | { kind: 'arrangement'; sections: Pattern[]; active: number } + | { kind: 'rendered'; path: string; duration_sec: number }; export type Command = | { type: 'get_snapshot' } @@ -100,6 +101,7 @@ export type Command = | { type: 'select_section'; index: number } | { type: 'clear_section'; index: number } | { type: 'copy_section'; from: number; to: number } + | { type: 'render' } | { type: 'undo' } | { type: 'redo' };