Record a take from the microphone in the Song tab (#342)
game-maker / gate (push) Successful in 3m32s
game-maker / suite (push) Failing after 15m51s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 20:58:17 +02:00
1 parent 32051b5097
commit 2fdf97f7de
17 files changed
+988 -21

No files matched your search

+5
View File
@@ -110,6 +110,11 @@ export class Scheduler {
return this.anchor !== null;
}
/** The second of the audio clock where it plays from (before any change of tempo); null while stopped. */
get startedAt(): number | null {
return this.anchor;
}
/** Played to its end, and none of its notes is heard any more. */
get done(): boolean {
return this.ended && this.voices.every((voice) => !this.host.playing(voice));
+111
View File
@@ -0,0 +1,111 @@
/**
* A take recorded in the Song tab (#342): the input's samples from where the song was heard to start
* on, as 16-bit mono WAV. The samples come with the frame of the audio clock they were taken at, so
* the take is cut to the first one that belongs to the start – what was played to the song lands on
* the bar it was played to, the output's and the input's delay taken off.
*/
import { nextNumberedName } from '../commands/naming';
import { newTrack, type SongTrack } from './song';
/** What a sound may weigh on the server, less a little for the header. */
export const MAX_TAKE_BYTES = 256 * 1024 * 1024 - 1024;
export const WAV_HEADER_BYTES = 44;
/** A run of samples as the input gave them, from `frame` of the audio clock on. */
export interface TakeChunk {
frame: number;
samples: Int16Array;
}
export function toPcm16(samples: Float32Array): Int16Array {
const out = new Int16Array(samples.length);
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]!));
out[i] = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff);
}
return out;
}
/**
* The second of the audio clock a sample has to be taken at to belong to the start: the song is
* heard `output` seconds after its anchor, and what is played to it reaches the input `input` later.
*/
export function takeStart(anchor: number, output: number, input: number): number {
return anchor + Math.max(0, output) + Math.max(0, input);
}
/** The chunks from `from` frames on: what came before is cut, a chunk that straddles it in two. */
export function trimmedTake(chunks: readonly TakeChunk[], from: number): Int16Array[] {
const out: Int16Array[] = [];
for (const chunk of chunks) {
const end = chunk.frame + chunk.samples.length;
if (end <= from) continue;
out.push(chunk.frame >= from ? chunk.samples : chunk.samples.subarray(Math.round(from - chunk.frame)));
}
return out;
}
export function sampleCount(parts: readonly Int16Array[]): number {
return parts.reduce((n, part) => n + part.length, 0);
}
/** The first `max` samples of the parts. */
export function cappedTake(parts: readonly Int16Array[], max: number): Int16Array[] {
const out: Int16Array[] = [];
let left = max;
for (const part of parts) {
if (left <= 0) break;
out.push(part.length <= left ? part : part.subarray(0, left));
left -= part.length;
}
return out;
}
/** How many samples fit into a sound the server takes. */
export function maxTakeSamples(): number {
return Math.floor((MAX_TAKE_BYTES - WAV_HEADER_BYTES) / 2);
}
/** The samples as a WAV file: RIFF, PCM, one channel, 16 bits. */
export function wavBytes(parts: readonly Int16Array[], sampleRate: number): Uint8Array<ArrayBuffer> {
const count = sampleCount(parts);
const bytes = new Uint8Array(WAV_HEADER_BYTES + count * 2);
const view = new DataView(bytes.buffer);
const text = (at: number, value: string) => {
for (let i = 0; i < value.length; i++) view.setUint8(at + i, value.charCodeAt(i));
};
const rate = Math.round(sampleRate);
text(0, 'RIFF');
view.setUint32(4, 36 + count * 2, true);
text(8, 'WAVE');
text(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, rate, true);
view.setUint32(28, rate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
text(36, 'data');
view.setUint32(40, count * 2, true);
let at = WAV_HEADER_BYTES;
for (const part of parts) {
for (let i = 0; i < part.length; i++, at += 2) view.setInt16(at, part[i]!, true);
}
return bytes;
}
/** "Song – Take 1", 2, 3 …: the first number no asset of the project has. */
export function takeName(songName: string, assetNames: readonly string[]): string {
return nextNumberedName(assetNames, `${songName} – Take`);
}
/** Where the take's own track goes: right under the track clicked last, else at the bottom. */
export function takeTrackIndex(tracks: readonly SongTrack[], picked: string): number {
const at = tracks.findIndex((t) => t.id === picked);
return at < 0 ? tracks.length : at + 1;
}
export function takeTrack(): SongTrack {
return { ...newTrack(1), name: 'Recording' };
}