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>
204 lines
7.5 KiB
TypeScript
204 lines
7.5 KiB
TypeScript
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|