At 4K and 60 fps the user's computer reaches about 39 fps in real time.
Reading each frame back from the GPU takes longer than a frame, and
neither reading back later nor the canvas route helped. With Real time
off the run moves on by one frame of the recording at a time, however
long the computer takes, so the file has every frame.
- Start recording has a new check Real time, on by default. Only the
node records so, the editor recording stays in real time.
- SteadyPace in recording.ts is the pace as a pure class. A step is
1/fps once the frame before is taken and the sound up to it is made,
otherwise 0 and the run holds. A frame's time is its number.
- The sound is made apart, as decided in the issue. AudioSwitch routes
what the run plays to the live library, or to the offline library of
a SteadySound while such a recording lasts. That library has the same
mix, buses, FX and limiter and is rendered up to each next frame.
- A driven video holds at every picture asked for, and the frame waits
for it. Its sound is decoded from the MP4 and played in step through
its bus.
- Two Chrome quirks measured and worked around. A held video hands
over the picture of a seek before seeked, so the frame callback is
asked for when the seek begins. A seek right onto where a picture
begins shows that one or the one before, so such a seek lands half a
millisecond further. Together 30 s became 10.7 s for 2 s of video.
- The notice tells how far the recording has got, and the line at the
end how long it took.
Deviations and limits:
- Input is not ignored, it still reaches the game, which runs slower.
- At most ten minutes per recording. Chrome holds the whole buffer of
the OfflineAudioContext while rendering, 230 MB for ten minutes.
- A free video plays on in real time and does not fit. A sound playing
before the start stays at the speakers.
Found along the way:
- A check in e2e 60 was red since 77365f1. It expected one log line,
the start line of the recording came on top. CI was off, so nobody
saw it. The expectation is updated.
Tests:
- First gate run red: 1 test in node-catalog.test.ts still expected
the settings of Start recording without realTime. Updated.
- Second gate run on melektaus-atx green: 310 files,
4818 tests.
- e2e 66 + 68 all passed, 50 checks, 68j new with 3 checks. ffmpeg
reads all 120 frames 1/60 s apart, the colour changes at frames
30/60/90, the video's sound from the first frame, the blip 8 ms off.
- e2e 60 (20 checks) and 67 green.
- Counter-check without the pace: all three 68j checks red, 32 of 119
frames, colour changes at 33/62/96, no sound. AudioSwitch's unit test
goes red when the ids are mixed up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
81 lines
3.4 KiB
TypeScript
81 lines
3.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { AudioSwitch, DIVERTED_IDS } from '../../src/runtime/audioSwitch';
|
|
import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
|
|
|
|
/** A sink that numbers its plays from `first` and writes down every call, with its clock at `clock`. */
|
|
function sinkFrom(first: number, clock = 0) {
|
|
const calls: string[] = [];
|
|
let next = first;
|
|
const sink: AudioSink & { calls: string[]; clockAt: number } = {
|
|
...SILENT_AUDIO,
|
|
calls,
|
|
clockAt: clock,
|
|
play: (file) => (calls.push(`play ${file}`), next++),
|
|
playSequence: (name) => (calls.push(`sequence ${name}`), next++),
|
|
stopVoice: (voice) => void calls.push(`stopVoice ${voice}`),
|
|
stopPlayback: (id) => void calls.push(`stopPlayback ${id}`),
|
|
tune: (voice) => void calls.push(`tune ${voice}`),
|
|
stop: (file) => void calls.push(`stop ${file}`),
|
|
stopOwner: (owner) => void calls.push(`stopOwner ${owner}`),
|
|
setMix: (buses) => void calls.push(`setMix ${buses.map((bus) => bus.name).join(',')}`),
|
|
setMixVolume: (bus, volume) => void calls.push(`setMixVolume ${bus} ${volume}`),
|
|
resetMix: () => void calls.push('resetMix'),
|
|
length: () => 2,
|
|
clock: () => sink.clockAt,
|
|
};
|
|
return sink;
|
|
}
|
|
|
|
describe('AudioSwitch (#423)', () => {
|
|
it('plays live until it is diverted, and reaches each voice in the sink that numbered it', () => {
|
|
const live = sinkFrom(1);
|
|
const aside = sinkFrom(DIVERTED_IDS);
|
|
const audio = new AudioSwitch(live);
|
|
const before = audio.play('music', 1, true, '');
|
|
audio.divert(aside);
|
|
const after = audio.play('hit', 1, false, '');
|
|
expect(before).toBe(1);
|
|
expect(after).toBe(DIVERTED_IDS);
|
|
audio.tune(before, {});
|
|
audio.stopVoice(after);
|
|
expect(live.calls).toEqual(['play music', 'tune 1']);
|
|
expect(aside.calls).toEqual(['play hit', `stopVoice ${DIVERTED_IDS}`]);
|
|
audio.restore();
|
|
expect(audio.play('again', 1, false, '')).toBe(2);
|
|
// One of the diverted sink, gone since, reaches nobody.
|
|
audio.stopVoice(after);
|
|
expect(aside.calls).toHaveLength(2);
|
|
});
|
|
|
|
it('stops by name, owner and everything in both, and hands the mix as it stands to the one it diverts to', () => {
|
|
const live = sinkFrom(1);
|
|
const aside = sinkFrom(DIVERTED_IDS);
|
|
const audio = new AudioSwitch(live);
|
|
audio.setMix([{ name: 'mix', volume: 1, into: null, fx: null }]);
|
|
audio.setMixVolume('mix:music', 0.5);
|
|
audio.divert(aside);
|
|
expect(aside.calls).toEqual(['setMix mix', 'setMixVolume mix:music 0.5']);
|
|
audio.stop('');
|
|
audio.stopOwner('o1');
|
|
expect(live.calls.slice(-2)).toEqual(['stop ', 'stopOwner o1']);
|
|
expect(aside.calls.slice(-2)).toEqual(['stop ', 'stopOwner o1']);
|
|
// The live sink knows every file, a streamed one too.
|
|
expect(audio.length('long')).toBe(2);
|
|
});
|
|
|
|
it('keeps time by the sink it plays to', () => {
|
|
const live = sinkFrom(1, 5);
|
|
const aside = sinkFrom(DIVERTED_IDS, 0.25);
|
|
const audio = new AudioSwitch(live);
|
|
const music = audio.playSequence('composition:c', {} as never, '');
|
|
expect(audio.clock()).toBe(5);
|
|
audio.divert(aside);
|
|
expect(audio.clock()).toBe(0.25);
|
|
// A composition that meets its picture again stops its live playing and starts on the other.
|
|
audio.stopPlayback(music, 0.02);
|
|
audio.playSequence('composition:c', {} as never, '');
|
|
expect(live.calls).toEqual(['sequence composition:c', 'stopPlayback 1']);
|
|
expect(aside.calls).toEqual(['sequence composition:c']);
|
|
});
|
|
});
|