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>
This commit is contained in:
1 parent
32051b5097
commit
2fdf97f7de
17 files changed
+988
-21
No files matched your search
+94
-1
@@ -12,7 +12,8 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
|
||||
* wheel zooms tracks or time, the strip above the ruler sets the loop. A head mutes, solos and sets
|
||||
* the volume of its track, a double-click sets a slider back to 0, its menu adds one. Space plays the song through the scheduler:
|
||||
* the pattern round and round at the song's tempo, the sound as a clip, each track on its own bus.
|
||||
* A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab.
|
||||
* A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab. Record
|
||||
* takes chromium's test tone onto a track of its own (#342), Esc throws a take away.
|
||||
* Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and clips
|
||||
* and what the buses were set to, as in e2e 64.
|
||||
*
|
||||
@@ -601,6 +602,98 @@ export async function song(t) {
|
||||
);
|
||||
const left = await rig.remove();
|
||||
|
||||
// 65l. Record (#342) plays from the cursor and takes chromium's test tone: the take grows red on a
|
||||
// track of its own under the one clicked, Space keeps it – WAV up, sound, track and item one
|
||||
// undo step –, Esc throws the next one away.
|
||||
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||||
await sleep(200);
|
||||
const before = await model();
|
||||
p = await json("__gm.song.headAt(0, 'name')");
|
||||
await click(p.x, p.y);
|
||||
p = await json(`__gm.song.rulerAt(${BAR})`);
|
||||
await click(p.x, p.y);
|
||||
p = await json('__gm.song.recordAt()');
|
||||
await click(p.x, p.y);
|
||||
const growing = await waitFor(
|
||||
() =>
|
||||
json('{ view: __gm.song.view(), recording: __gm.song.recording(), button: __gm.song.recordAt().text }'),
|
||||
(s) => s.view.take?.peaks > 120,
|
||||
60,
|
||||
);
|
||||
await shot('03-recording');
|
||||
check(
|
||||
'Record plays from the cursor and grows the take, red, on a new track under the one clicked',
|
||||
growing.recording &&
|
||||
growing.button === 'Stop' &&
|
||||
growing.view.playhead !== null &&
|
||||
growing.view.take?.index === 1 &&
|
||||
growing.view.take.tick === BAR &&
|
||||
growing.view.take.length > 0 &&
|
||||
!growing.view.take.uploading,
|
||||
JSON.stringify(growing),
|
||||
);
|
||||
await press('Space');
|
||||
const kept = await waitFor(
|
||||
() => model(),
|
||||
(m) => m.tracks.length === before.tracks.length + 1,
|
||||
60,
|
||||
);
|
||||
const takeTrack = kept.tracks[1];
|
||||
const takeItem = kept.items.find((i) => i.track === takeTrack?.id);
|
||||
const takeAsset = await json(
|
||||
`(() => { const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(takeItem?.asset ?? '')}); return a ? { name: a.name, file: a.file, type: a.type } : null; })()`,
|
||||
);
|
||||
const songName = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}).name`);
|
||||
const takeSeconds = takeAsset ? await evaluate(`__gm.sound.load(${JSON.stringify(takeAsset.file)})`) : 0;
|
||||
check(
|
||||
'Space keeps it: a WAV sound named after the song, on the track "Recording", from the cursor, as long as it is',
|
||||
kept.tracks[0]?.id === before.tracks[0]?.id &&
|
||||
takeTrack?.name === 'Recording' &&
|
||||
takeItem?.kind === 'sound' &&
|
||||
takeItem.tick === BAR &&
|
||||
takeAsset?.type === 'sound' &&
|
||||
takeAsset.name === `${songName} – Take 1` &&
|
||||
/^[a-f0-9]{64}\.wav$/.test(takeAsset.file) &&
|
||||
takeSeconds > 1 &&
|
||||
Math.abs(takeItem.length - Math.floor(takeSeconds * 960)) <= 1 &&
|
||||
!(await json('__gm.song.recording()')) &&
|
||||
(await json('__gm.song.view()')).take === null,
|
||||
JSON.stringify({ kept, takeAsset, takeSeconds }),
|
||||
);
|
||||
await press('z', { ctrl: true });
|
||||
const undone = await model();
|
||||
check(
|
||||
'one Ctrl+Z takes sound, track and item back',
|
||||
undone.tracks.length === before.tracks.length &&
|
||||
undone.items.length === before.items.length &&
|
||||
(await json(
|
||||
`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(takeItem?.asset ?? '')})`,
|
||||
)) === false,
|
||||
JSON.stringify(undone),
|
||||
);
|
||||
const assetCount = await json('__gm.state.project.assets.length');
|
||||
p = await json('__gm.song.recordAt()');
|
||||
await click(p.x, p.y);
|
||||
await waitFor(
|
||||
() => json('__gm.song.view()'),
|
||||
(v) => v.take?.peaks > 20,
|
||||
60,
|
||||
);
|
||||
await press('Escape');
|
||||
await sleep(300);
|
||||
const thrown = await json(
|
||||
'{ recording: __gm.song.recording(), take: __gm.song.view().take, playing: __gm.song.playing(), assets: __gm.state.project.assets.length }',
|
||||
);
|
||||
check(
|
||||
'Esc while recording throws the take away: nothing is added, playing stops',
|
||||
!thrown.recording &&
|
||||
thrown.take === null &&
|
||||
!thrown.playing &&
|
||||
thrown.assets === assetCount &&
|
||||
(await model()).tracks.length === before.tracks.length,
|
||||
JSON.stringify(thrown),
|
||||
);
|
||||
|
||||
// 65j. The step takes back everything it made.
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
await mutate(`
|
||||
|
||||
@@ -14,6 +14,9 @@ const COMMON = [
|
||||
// asked for one. Muted plus autoplay-allowed is exactly that: it plays, silently.
|
||||
'--mute-audio',
|
||||
'--autoplay-policy=no-user-gesture-required',
|
||||
// A take of the Song tab (#342) records chromium's test tone, without asking for leave.
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--use-fake-ui-for-media-stream',
|
||||
];
|
||||
|
||||
const HEADLESS = [
|
||||
|
||||
Reference in new issue
Block a user