refactor: require validatedDurationSec in AudioProcessor, drop fallbacks

AudioProcessor.process and renderPitchPreservedTimelineAudio accepted
validatedDurationSec as optional, so the speed-aware path fell back to
media.duration when it was absent. HTMLMediaElement.duration can be
Infinity for the same MediaRecorder/Chromium Linux containers this PR
targets, which would make effectiveEnd and the playback stop checks
unreliable.

The only caller (VideoExporter.process) already threads
streamingDecoder's validatedDuration through, so make the parameter
required. Drop the media.duration fallback, the Number.isFinite guard
on readEndSec, and the two `!== undefined` checks in the tick loop.

While here:
- Document that +0.5 on readEndSec mirrors streamingDecoder.decodeAll's
  read window so trim-only and speed-aware paths stay in sync.
- Replace the unreachable silent-blob fallback at the end of
  renderPitchPreservedTimelineAudio with a loud invariant throw, so a
  broken recorder contract surfaces instead of yielding empty audio.
This commit is contained in:
Enriquefft
2026-04-16 14:49:27 -05:00
parent 0c01db7afa
commit dd8c001f6d
+14 -15
View File
@@ -19,9 +19,9 @@ export class AudioProcessor {
demuxer: WebDemuxer,
muxer: VideoMuxer,
videoUrl: string,
trimRegions?: TrimRegion[],
speedRegions?: SpeedRegion[],
validatedDurationSec?: number,
trimRegions: TrimRegion[] | undefined,
speedRegions: SpeedRegion[] | undefined,
validatedDurationSec: number,
): Promise<void> {
const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : [];
const sortedSpeedRegions = speedRegions
@@ -46,10 +46,9 @@ export class AudioProcessor {
}
// No speed edits: keep the original demux/decode/encode path with trim timestamp remap.
const readEndSec =
typeof validatedDurationSec === "number" && Number.isFinite(validatedDurationSec)
? validatedDurationSec + 0.5
: undefined;
// The +0.5s buffer mirrors streamingDecoder.decodeAll's read window so the trim-only
// and speed-aware paths agree on how far to read past the validated duration boundary.
const readEndSec = validatedDurationSec + 0.5;
await this.processTrimOnlyAudio(demuxer, muxer, sortedTrims, readEndSec);
}
@@ -193,7 +192,7 @@ export class AudioProcessor {
videoUrl: string,
trimRegions: TrimRegion[],
speedRegions: SpeedRegion[],
validatedDurationSec?: number,
validatedDurationSec: number,
): Promise<Blob> {
const media = document.createElement("audio");
media.src = videoUrl;
@@ -230,7 +229,7 @@ export class AudioProcessor {
// Skip past any initial trim region(s) before recording starts to avoid
// capturing trimmed audio during the first rAF frames of playback.
// Loops to handle back-to-back or overlapping trims at t=0.
const effectiveEnd = validatedDurationSec ?? media.duration;
const effectiveEnd = validatedDurationSec;
let startPosition = 0;
for (let i = 0; i <= trimRegions.length; i++) {
const activeTrim = this.findActiveTrimRegion(startPosition * 1000, trimRegions);
@@ -287,7 +286,7 @@ export class AudioProcessor {
// Stop playback at validated duration — browser's media.duration
// may be inflated from bad container metadata.
if (validatedDurationSec !== undefined && media.currentTime >= validatedDurationSec) {
if (media.currentTime >= validatedDurationSec) {
media.pause();
cleanup();
resolve();
@@ -299,10 +298,7 @@ export class AudioProcessor {
if (activeTrimRegion && !media.paused && !media.ended) {
const skipToTime = activeTrimRegion.endMs / 1000;
if (
skipToTime >= media.duration ||
(validatedDurationSec !== undefined && skipToTime >= validatedDurationSec)
) {
if (skipToTime >= media.duration || skipToTime >= validatedDurationSec) {
media.pause();
cleanup();
resolve();
@@ -379,7 +375,10 @@ export class AudioProcessor {
}
if (!recordedBlobPromise) {
return new Blob([], { type: "audio/webm" });
// Invariant: either an early return above fires, or startAudioRecording ran and
// populated recordedBlobPromise before the playback Promise resolved. Reaching
// here means that contract was broken — fail loud instead of returning silence.
throw new Error("Audio recorder finished without assigning recordedBlobPromise");
}
const recordedBlob = await recordedBlobPromise;
if (this.cancelled) {