Files
feuerwerk/src/viewport/takeRecorder.ts
T
adminandClaude Opus 5.5 2fdf97f7de
game-maker / gate (push) Successful in 3m32s
game-maker / suite (push) Failing after 15m51s
Record a take from the microphone in the Song tab (#342)
A song could only be filled with sounds that were made somewhere else.
Record plays the song from the cursor and takes the input meanwhile, so
a part played along lands on the bar it was played to.

- An audio worklet takes the input's samples on the clock the song
  plays on. Each run carries the frame it was taken at, so the take is
  cut where the song was heard to start. The output's and the input's
  latency are taken off, as far as the browser reports them.
- The take goes up as mono 16-bit WAV. Sound, a new track "Recording"
  under the track clicked last and the item at the cursor are one undo
  step. The sound is named "Song – Take 1", 2, 3 and so on.
- While recording the take grows red with its waveform on its track,
  and a meter beside the input dropdown shows the level. The lanes take
  no edits then, only a middle-drag pans.
- The loop is not used while recording. The song plays once through
  and on past its end until Stop, Play or Space keep the take. Esc
  throws it away and nothing goes up.
- Echo cancelling, noise suppression and auto gain are off, nothing is
  played back. The input picked is kept in localStorage.
- A take stops by itself at the size a sound may have. A failed upload
  offers Retry, Download WAV and Discard.
- The API takes sounds up to 256 MB and bodies of the same size. nginx
  and PHP were raised to match outside the repo (body 256m, upload and
  post 256M, memory_limit 512M).
- The pure rules (cut, cap, WAV header, name, track) live in
  core/music/take.ts. The scheduler tells where it started on the
  audio clock.
- The e2e browser gets chromium's fake microphone and skips the
  permission prompt. Each run leaves its take in the sound store.

Found on the way:
- The header was too full with the new widgets and cut the track and
  item count on the right. Input dropdown and meter are narrower now.

Tests:
- Unit take.test.ts: 7 new, svg-icons adjusted to 8 toolbar icons
- e2e 65 (melektaus-atx): ALL PASSED – 31 checks (+4)
- Gate (melektaus-atx): CHECK EXIT 0, 252 files, 3923 tests
- Probe on stein2: a 3 s take of the test tone, peak 32767,
  285100 bytes, waveform seen in the screenshot

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:58:17 +02:00

204 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cappedTake, maxTakeSamples, type TakeChunk, toPcm16, trimmedTake } from '../core/music/take';
import { soundLibrary } from './SoundLibrary';
import takeWorklet from './takeWorklet.ts?worker&url';
/** Samples per column of the waveform drawn while recording. */
export const PEAK_SPAN = 512;
/** How fast the level meter falls back, per message of the worklet. */
const LEVEL_FALL = 0.85;
/** How long the worklet gets to send its last samples before the take is closed without them. */
const LAST_WORDS_MS = 1000;
export interface InputDevice {
id: string;
label: string;
}
const loaded = new WeakMap<BaseAudioContext, Promise<void>>();
function loadWorklet(context: AudioContext): Promise<void> {
let loading = loaded.get(context);
if (!loading) {
loading = context.audioWorklet.addModule(takeWorklet);
loaded.set(context, loading);
void loading.catch(() => loaded.delete(context));
}
return loading;
}
/** The inputs the browser offers; their names only once recording has been allowed. */
export async function inputDevices(): Promise<InputDevice[]> {
const devices = (await media()?.enumerateDevices()) ?? [];
// Chrome lists its own "default" and "communications" besides the real ones.
return devices
.filter((d) => d.kind === 'audioinput' && !['', 'default', 'communications'].includes(d.deviceId))
.map((d, i) => ({ id: d.deviceId, label: d.label || `Input ${i + 1}` }));
}
/** Missing outside a secure page, whatever the types say. */
function media(): MediaDevices | undefined {
const page: { mediaDevices?: MediaDevices } = navigator;
return page.mediaDevices;
}
/** Why recording could not start, said so a person can do something about it. */
export function inputProblem(error: unknown): string {
const name = error instanceof Error ? error.name : '';
if (name === 'NotAllowedError' || name === 'SecurityError')
return 'Recording is not allowed – allow the microphone with the lock in the address bar.';
if (name === 'NotFoundError' || name === 'OverconstrainedError') return 'No microphone found.';
if (name === 'NotReadableError') return 'The microphone is busy in another program.';
return `Cannot record: ${error instanceof Error ? error.message : String(error)}`;
}
/**
* The microphone on the sound library's clock (#342): its samples arrive with the frame they were
* taken at, so the take can be cut exactly where the song was heard to start. The browser's echo
* cancelling, noise filter and gain control are off – they bend music. Nothing is played back.
*/
export class TakeRecorder {
readonly sampleRate: number;
/** Seconds the input lags behind what reaches it, where the browser says. */
readonly inputLatency: number;
/** One per `PEAK_SPAN` samples since the start, 0 to 1. */
readonly peaks: number[] = [];
/** The loudest of the last moment, 0 to 1. */
level = 0;
/** As long as a sound may be: nothing more is kept. */
full = false;
private readonly chunks: TakeChunk[] = [];
/** The frame of the start, unknown until the song has started. */
private from = Infinity;
private kept = 0;
private peakChunk = 0;
private peak = 0;
private peakCount = 0;
private closed = false;
private done: (() => void) | null = null;
private constructor(
context: AudioContext,
private readonly stream: MediaStream,
private readonly nodes: AudioNode[],
private readonly node: AudioWorkletNode,
) {
this.sampleRate = context.sampleRate;
const settings = stream.getAudioTracks()[0]?.getSettings() as { latency?: number } | undefined;
this.inputLatency = Number.isFinite(settings?.latency) ? Math.max(0, settings!.latency!) : 0;
node.port.onmessage = (event: MessageEvent) => {
if (event.data === 'done') this.done?.();
else this.take(event.data as { frame: number; samples: Float32Array });
};
}
/** Asks for the input – the first time, the browser asks the user – and starts taking samples. */
static async open(deviceId: string): Promise<TakeRecorder> {
const devices = media();
if (!devices) throw new Error('this page cannot record – it needs https');
const context = soundLibrary.liveContext();
const stream = await devices.getUserMedia({
audio: {
...(deviceId && { deviceId: { ideal: deviceId } }),
channelCount: { ideal: 1 },
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});
try {
await loadWorklet(context);
if (context.state === 'suspended') await context.resume();
const source = context.createMediaStreamSource(stream);
const node = new AudioWorkletNode(context, 'game-maker-take', {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [1],
});
// Silent, but on the way to the speakers: a node nobody listens to may not be run.
const sink = context.createGain();
sink.gain.value = 0;
source.connect(node).connect(sink).connect(context.destination);
return new TakeRecorder(context, stream, [source, node, sink], node);
} catch (error) {
for (const track of stream.getTracks()) track.stop();
throw error;
}
}
/** The second of the audio clock the take starts at; what came before is dropped. */
startAt(seconds: number): void {
this.from = Math.round(seconds * this.sampleRate);
this.feed();
}
get started(): boolean {
return Number.isFinite(this.from);
}
/** Seconds kept since the start. */
get seconds(): number {
return this.kept / this.sampleRate;
}
/** Stops taking samples and gives back the take, cut to its start. */
async finish(): Promise<Int16Array[]> {
if (!this.closed) {
const last = new Promise<void>((resolve) => {
this.done = resolve;
setTimeout(resolve, LAST_WORDS_MS);
});
this.node.port.postMessage('stop');
await last;
this.close();
}
const from = this.started ? this.from : Infinity;
return cappedTake(trimmedTake(this.chunks, from), maxTakeSamples());
}
/** Stops taking samples and forgets them. */
cancel(): void {
this.close();
this.chunks.length = 0;
}
private close(): void {
if (this.closed) return;
this.closed = true;
this.node.port.onmessage = null;
for (const node of this.nodes) node.disconnect();
for (const track of this.stream.getTracks()) track.stop();
}
private take({ frame, samples }: { frame: number; samples: Float32Array }): void {
let loudest = 0;
for (const s of samples) loudest = Math.max(loudest, Math.abs(s));
this.level = Math.max(loudest, this.level * LEVEL_FALL);
if (this.full) return;
// Before the start is known everything is kept; after it, only what is not before it.
if (this.started && frame + samples.length <= this.from) return;
this.chunks.push({ frame, samples: toPcm16(samples) });
this.feed();
}
/** Counts and draws what has arrived since the start. */
private feed(): void {
if (!this.started) return;
const max = maxTakeSamples();
for (; this.peakChunk < this.chunks.length; this.peakChunk++) {
const { frame, samples } = this.chunks[this.peakChunk]!;
for (let i = Math.max(0, Math.round(this.from - frame)); i < samples.length; i++) {
this.peak = Math.max(this.peak, Math.abs(samples[i]!) / 0x8000);
if (++this.peakCount === PEAK_SPAN) {
this.peaks.push(this.peak);
this.peak = 0;
this.peakCount = 0;
}
if (++this.kept >= max) {
this.full = true;
return;
}
}
}
}
}