P2b: offline WAV render of the arrangement
This commit is contained in:
Generated
+1
@@ -1787,6 +1787,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"hound",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
@@ -18,6 +18,8 @@ This repository is **P2 in progress**: the engine, the wire, the descent, the qu
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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 `<project>/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).
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+8
-2
@@ -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 `<project>/renders/<project>-<ms>.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)
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 604 KiB |
@@ -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');
|
||||
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
@@ -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 @@
|
||||
<div class="history" aria-label="history">
|
||||
<button class="history-button" onclick={undo} disabled={!project?.can_undo} aria-label="undo" title="undo · ctrl+z">undo</button>
|
||||
<button class="history-button" onclick={redo} disabled={!project?.can_redo} aria-label="redo" title="redo · shift+ctrl+z">redo</button>
|
||||
<button
|
||||
class="history-button render-button"
|
||||
onclick={renderProject}
|
||||
disabled={rendering || status !== 'live'}
|
||||
title={lastRender ? `last render: ${lastRender}` : 'render the arrangement to WAV'}
|
||||
>{rendering ? 'rendering…' : 'render'}</button>
|
||||
</div>
|
||||
<div class="net" class:is-live={status === 'live'}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
|
||||
@@ -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';
|
||||
|
||||
+3
-1
@@ -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' };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user