P1c: microphone recording through the material pipeline

This commit is contained in:
Emil
2026-11-01 01:44:36 +03:00
parent 0acf11ccb0
commit 88663a384e
14 changed files with 421 additions and 18 deletions
+1
View File
@@ -11,6 +11,7 @@
"smoke": "node scripts/smoke.mjs",
"quarry-smoke": "node scripts/quarry-smoke.mjs",
"project-smoke": "node scripts/project-smoke.mjs",
"record-smoke": "node scripts/record-smoke.mjs",
"restored-smoke": "node scripts/restored-smoke.mjs"
},
"devDependencies": {
+60
View File
@@ -0,0 +1,60 @@
import { chromium } from 'playwright-core';
import path from 'node:path';
const chrome = process.env.CHROME_PATH ?? '/home/emil/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome';
const fixture = path.resolve('../fixtures/clicks-01.wav');
const browser = await chromium.launch({
executablePath: chrome,
args: [
'--no-sandbox',
'--autoplay-policy=no-user-gesture-required',
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
`--use-file-for-fake-audio-capture=${fixture}`
]
});
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 });
const errors = [];
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
page.on('console', (message) => { if (message.type() === 'error') errors.push(`console: ${message.text()}`); });
await page.goto('http://127.0.0.1:5173/', { waitUntil: 'load' });
await page.waitForTimeout(2200);
await page.locator('.room', { hasText: 'quarry' }).click();
await page.waitForTimeout(500);
const record = page.locator('.quarry-actions .record');
await record.click();
await page.waitForSelector('.quarry-actions .record.is-on', { timeout: 5000 });
await page.waitForTimeout(2300);
const during = await record.textContent();
await record.click();
await page.waitForFunction(
() => [...document.querySelectorAll('.core-name')].some((el) => el.textContent?.includes('take-01')),
null,
{ timeout: 25000 }
);
await page.waitForTimeout(900);
await page.screenshot({ path: '../preview-p1c-record.png' });
const report = await page.evaluate(() => ({
cores: [...document.querySelectorAll('.core-name')].map((el) => el.textContent),
faces: document.querySelectorAll('.face').length,
selected: document.querySelector('.core-tab.is-on .core-name')?.textContent,
stats: document.querySelector('.face-stats')?.textContent?.replace(/\s+/g, ' ').trim(),
wire: [...document.querySelectorAll('.wire-label')].map((el) => el.textContent).slice(-6),
recordText: document.querySelector('.quarry-actions .record')?.textContent?.trim()
}));
console.log(JSON.stringify({ during, report, errors }, null, 2));
const failures = [];
if (!during || !during.includes('stop')) failures.push('record button did not switch to stop');
if (!report.cores.includes('take-01')) failures.push('take-01 was not imported');
if (report.faces !== 16) failures.push('take did not produce 16 faces');
if (!report.stats || !report.stats.includes('recorded')) failures.push('seal is not recorded');
if (errors.length) failures.push('console errors');
await browser.close();
if (failures.length) {
console.error('RECORD SMOKE FAILED: ' + failures.join('; '));
process.exit(1);
}
console.log('record smoke passed');
+3 -2
View File
@@ -15,6 +15,7 @@
Event,
HelloMessage,
MeterFrame,
Seal,
State,
Status,
VeinId,
@@ -143,13 +144,13 @@
void run({ type: 'set_vein_gain', vein, gain });
}
async function importCore(file: File) {
async function importCore(file: File, seal: Seal = 'found') {
if (!bridge || importing) return;
importing = true;
addEntry('send', `import ${file.name}`);
try {
const data = await file.arrayBuffer();
const core = await bridge.importCore(file.name, 'found', data);
const core = await bridge.importCore(file.name, seal, data);
selectedCoreId = core.id;
selectedFace = core.faces.find((face) => face.rank === 0)?.index ?? 0;
addEntry('recv', 'core', `${core.name} · ${core.faces.length} faces · ${core.duration_sec.toFixed(1)} s`);
+74
View File
@@ -1167,6 +1167,71 @@ button.room {
cursor: wait;
}
.record {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: 1px solid rgba(155, 133, 121, 0.28);
background: transparent;
color: var(--bone);
font-size: 9.5px;
letter-spacing: 0.03em;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
}
.record:hover:not(:disabled) {
border-color: var(--ember);
}
.record.is-on {
border-color: var(--ember);
background: rgba(232, 64, 42, 0.06);
color: var(--ember);
}
.record:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.record-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--ember);
}
.record.is-on .record-dot {
animation: pulse-dot 1.1s ease-in-out infinite;
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(232, 64, 42, 0.5);
}
50% {
opacity: 0.4;
box-shadow: 0 0 0 4px rgba(232, 64, 42, 0);
}
}
.record-error {
margin: 8px 0 0;
font-size: 9px;
line-height: 1.6;
color: var(--ember);
}
.empty-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.file {
position: absolute;
width: 1px;
@@ -1795,12 +1860,21 @@ button.room {
.quarry-head h1 {
font-size: 26px;
}
.quarry-head {
flex-wrap: wrap;
}
.quarry-actions {
width: 100%;
justify-content: flex-end;
gap: 6px;
}
.import-state {
display: none;
}
.record {
padding: 6px 8px;
font-size: 8.5px;
}
.face-wall {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
+86 -8
View File
@@ -2,7 +2,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { CoreSummary, Pattern } from '../lib/types';
import type { CoreSummary, Pattern, Seal } from '../lib/types';
import { MAX_RECORD_SECONDS, startRecording, stopRecording } from '../lib/record';
let {
cores,
@@ -28,7 +29,7 @@
onselectcore: (id: string) => void;
onselectface: (face: number) => void;
onselectrow: (row: number) => void;
onimport: (file: File) => void;
onimport: (file: File, seal?: Seal) => void;
onassign: () => void;
onstrikeface: (core: string, face: number) => void;
onstep: (row: number, step: number, on: boolean) => void;
@@ -36,6 +37,11 @@
let fileInput: HTMLInputElement | null = null;
let wave: HTMLCanvasElement | null = $state(null);
let recording = $state(false);
let recordSeconds = $state(0);
let recordError = $state<string | null>(null);
let finishing = $state(false);
let takeIndex = $state(1);
let observer: ResizeObserver | null = null;
const core = $derived(cores.find((item) => item.id === selectedCore) ?? cores[0] ?? null);
@@ -117,7 +123,53 @@
}
function pickFile() {
fileInput?.click();
if (!importing && !recording) fileInput?.click();
}
function formatTime(seconds: number): string {
const whole = Math.floor(seconds);
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
}
async function toggleRecording() {
if (finishing) return;
if (!recording) {
recordError = null;
recordSeconds = 0;
try {
await startRecording((seconds) => {
recordSeconds = seconds;
if (seconds >= MAX_RECORD_SECONDS) void finishRecording();
});
recording = true;
} catch (error) {
recordError =
error instanceof Error ? error.message : 'the microphone could not be opened';
}
return;
}
await finishRecording();
}
async function finishRecording() {
if (finishing || !recording) return;
finishing = true;
try {
const take = await stopRecording();
recording = false;
if (!take) {
recordError = 'nothing was captured';
return;
}
const name = `take-${String(takeIndex).padStart(2, '0')}.wav`;
takeIndex += 1;
onimport(new File([take.blob], name, { type: 'audio/wav' }), 'recorded');
} catch (error) {
recording = false;
recordError = error instanceof Error ? error.message : 'the take could not be closed';
} finally {
finishing = false;
}
}
function fileChanged() {
@@ -143,7 +195,20 @@
<span class="import-state">
{importing ? 'cutting…' : `${cores.length} core${cores.length === 1 ? '' : 's'}`}
</span>
<button class="import" onclick={pickFile} disabled={importing}>take a core</button>
<button
class="record"
class:is-on={recording}
onclick={toggleRecording}
disabled={importing || finishing}
aria-label={recording ? 'stop recording' : 'record a take'}
title={recording ? 'stop and take the core' : 'record from the microphone'}
>
<span class="record-dot" aria-hidden="true"></span>
{recording ? `stop ${formatTime(recordSeconds)}` : 'record'}
</button>
<button class="import" onclick={pickFile} disabled={importing || recording}>
take a core
</button>
<input
class="file"
bind:this={fileInput}
@@ -160,11 +225,24 @@
<div class="quarry-empty">
<p class="empty-title">the archive is empty.</p>
<p class="empty-copy">
Import a field recording. The quarry finds the seams and cuts sixteen faces. They can be
struck on their own or laid on a vein.
Import a field recording or record a take. The quarry finds the seams and cuts sixteen
faces. They can be struck on their own or laid on a vein.
</p>
<button class="empty-action" onclick={pickFile} disabled={importing}>take a core</button>
<p class="empty-formats">WAV · FLAC · MP3 · OGG</p>
<div class="empty-actions">
<button class="empty-action" onclick={pickFile} disabled={importing || recording}>
take a core
</button>
<button
class="record empty-record"
class:is-on={recording}
onclick={toggleRecording}
disabled={importing || finishing}
>
<span class="record-dot" aria-hidden="true"></span>
{recording ? `stop ${formatTime(recordSeconds)}` : 'record a take'}
</button>
</div>
<p class="empty-formats">WAV · FLAC · MP3 · OGG · or the microphone</p>
</div>
{:else if core}
<div class="core-strip" aria-label="cores">
+123
View File
@@ -0,0 +1,123 @@
import workletUrl from './recorder-worklet.js?url';
export const MAX_RECORD_SECONDS = 30;
interface ActiveRecorder {
context: AudioContext;
stream: MediaStream;
node: AudioWorkletNode;
mute: GainNode;
chunks: Float32Array[];
started: number;
timer: number;
}
let active: ActiveRecorder | null = null;
export async function startRecording(tick: (seconds: number) => void): Promise<void> {
if (active) return;
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
}
});
const context = new AudioContext({ sampleRate: 48000, latencyHint: 'interactive' });
try {
await context.audioWorklet.addModule(workletUrl);
} catch (error) {
stream.getTracks().forEach((track) => track.stop());
await context.close();
throw error;
}
const source = context.createMediaStreamSource(stream);
const node = new AudioWorkletNode(context, 'zhila-recorder', {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [1]
});
const chunks: Float32Array[] = [];
node.port.onmessage = (event: MessageEvent<Float32Array>) => {
if (event.data instanceof Float32Array) chunks.push(event.data);
};
// The processor needs a path to the destination to run; the mute keeps the
// microphone out of the speakers.
const mute = context.createGain();
mute.gain.value = 0;
source.connect(node);
node.connect(mute);
mute.connect(context.destination);
if (context.state === 'suspended') await context.resume();
const started = performance.now();
const timer = window.setInterval(() => {
tick(Math.min(MAX_RECORD_SECONDS, (performance.now() - started) / 1000));
}, 100);
active = { context, stream, node, mute, chunks, started, timer };
}
export async function stopRecording(): Promise<{
blob: Blob;
seconds: number;
sampleRate: number;
} | null> {
if (!active) return null;
const recorder = active;
active = null;
window.clearInterval(recorder.timer);
recorder.node.port.onmessage = null;
recorder.node.disconnect();
recorder.mute.disconnect();
recorder.stream.getTracks().forEach((track) => track.stop());
const seconds = (performance.now() - recorder.started) / 1000;
const sampleRate = recorder.context.sampleRate;
await recorder.context.close();
const length = recorder.chunks.reduce((total, chunk) => total + chunk.length, 0);
if (length === 0) return null;
const samples = new Float32Array(length);
let offset = 0;
for (const chunk of recorder.chunks) {
samples.set(chunk, offset);
offset += chunk.length;
}
return { blob: encodeWav(samples, sampleRate), seconds, sampleRate };
}
export function isRecording(): boolean {
return active !== null;
}
function encodeWav(samples: Float32Array, sampleRate: number): Blob {
const channels = 1;
const bytesPerSample = 2;
const dataBytes = samples.length * bytesPerSample;
const buffer = new ArrayBuffer(44 + dataBytes);
const view = new DataView(buffer);
const text = (offset: number, value: string) => {
for (let index = 0; index < value.length; index += 1) {
view.setUint8(offset + index, value.charCodeAt(index));
}
};
text(0, 'RIFF');
view.setUint32(4, 36 + dataBytes, true);
text(8, 'WAVE');
text(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * channels * bytesPerSample, true);
view.setUint16(32, channels * bytesPerSample, true);
view.setUint16(34, bytesPerSample * 8, true);
text(36, 'data');
view.setUint32(40, dataBytes, true);
let offset = 44;
for (let index = 0; index < samples.length; index += 1) {
const sample = Math.max(-1, Math.min(1, samples[index]));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
offset += 2;
}
return new Blob([buffer], { type: 'audio/wav' });
}
+11
View File
@@ -0,0 +1,11 @@
// Runs on the audio thread. Copies each render quantum; the main thread owns
// the take buffer and the WAV encoding.
class ZhilaRecorderProcessor extends AudioWorkletProcessor {
process(inputs) {
const channel = inputs[0] && inputs[0][0];
if (channel && channel.length) this.port.postMessage(channel.slice(0));
return true;
}
}
registerProcessor('zhila-recorder', ZhilaRecorderProcessor);
+1 -1
View File
@@ -1,7 +1,7 @@
// Wire types for protocol v1. Kept in step with crates/zhila-protocol.
export type VeinId = 'bass' | 'crack' | 'voice' | 'noise' | 'silt';
export type Status = 'connecting' | 'live' | 'offline';
export type Seal = 'found' | 'taken' | 'gift' | 'loan' | 'unknown';
export type Seal = 'found' | 'recorded' | 'taken' | 'gift' | 'loan' | 'unknown';
export interface FaceSummary {
index: number;