mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
fix(voice): record the real length and write it into the file
The recording clock counted setInterval ticks, which a background tab throttles away: a recording that kept going looked frozen at 13 seconds and was saved with that length. It now reads the wall clock instead. MediaRecorder also writes WebM with no usable duration. Chrome omits the element entirely, Firefox reserves a Duration of 0.0 it never fills in, so players had no length to show and played past the end of the seek bar. lib/webm-duration.ts stamps the recorded length into Segment > Info when the recording stops, in place where the browser reserved room for it.
This commit is contained in:
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
import { act, renderHook, type RenderHookResult } from '@testing-library/react';
|
||||
import { useCommentActions } from '@/components/video-page/hooks/use-comment-actions';
|
||||
import type { Comment, CommentTag, VideoData } from '@/components/video-page/types';
|
||||
import { buildLiveWebm, readWebmDuration } from '../../helpers/webm-fixture';
|
||||
|
||||
const toastError = vi.fn();
|
||||
const toastSuccess = vi.fn();
|
||||
@@ -871,3 +872,147 @@ describe('useCommentActions background refresh', () => {
|
||||
expect(stableDeps.fetchVersionComments).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Two bugs lived here. The recording clock counted setInterval ticks, which a
|
||||
// background tab throttles away, so a recording that kept going looked frozen
|
||||
// and was saved with the short length. And MediaRecorder writes WebM with no
|
||||
// duration at all, so the uploaded file played past a length no player knew.
|
||||
describe('useCommentActions voice recording', () => {
|
||||
let recorders: FakeMediaRecorder[];
|
||||
let recordedChunk: Uint8Array;
|
||||
|
||||
class FakeMediaRecorder {
|
||||
static isTypeSupported = () => true;
|
||||
state: 'inactive' | 'recording' = 'inactive';
|
||||
mimeType: string;
|
||||
ondataavailable: ((event: { data: Blob }) => void) | null = null;
|
||||
onstop: (() => void) | null = null;
|
||||
|
||||
constructor(_stream: unknown, options: { mimeType: string }) {
|
||||
this.mimeType = options.mimeType;
|
||||
recorders.push(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
this.state = 'recording';
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.state = 'inactive';
|
||||
this.ondataavailable?.({
|
||||
data: new Blob([recordedChunk.buffer as ArrayBuffer], { type: this.mimeType }),
|
||||
});
|
||||
this.onstop?.();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
recorders = [];
|
||||
recordedChunk = buildLiveWebm();
|
||||
vi.stubGlobal('MediaRecorder', FakeMediaRecorder);
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
mediaDevices: {
|
||||
getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }] }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function durationOf(blob: Blob | null): Promise<number | null> {
|
||||
if (!blob) return null;
|
||||
return readWebmDuration(new Uint8Array(await blob.arrayBuffer()));
|
||||
}
|
||||
|
||||
it('counts the time the tab spent in the background', async () => {
|
||||
const harness = renderActions();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.startRecording();
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(13_000);
|
||||
});
|
||||
expect(harness.result.current.actions.recordingTime).toBeCloseTo(13, 1);
|
||||
|
||||
// The tab goes to the background: the clock moves on, the interval does not fire.
|
||||
vi.setSystemTime(Date.now() + 10_000);
|
||||
await act(async () => {
|
||||
harness.result.current.actions.stopRecording();
|
||||
});
|
||||
|
||||
expect(harness.result.current.actions.recordingTime).toBeCloseTo(23, 1);
|
||||
});
|
||||
|
||||
it('saves the comment with the length that was actually recorded', async () => {
|
||||
const harness = renderActions();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.startRecording();
|
||||
});
|
||||
vi.setSystemTime(Date.now() + 23_000);
|
||||
await act(async () => {
|
||||
harness.result.current.actions.stopRecording();
|
||||
});
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.submitCommentWithMedia();
|
||||
});
|
||||
|
||||
const [post] = callsTo(`/api/versions/${ACTIVE_VERSION}/comments`, 'POST');
|
||||
expect(bodyOf(post).voiceDuration).toBeCloseTo(23, 1);
|
||||
});
|
||||
|
||||
it('stamps the recorded length into the uploaded webm', async () => {
|
||||
const harness = renderActions();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.startRecording();
|
||||
});
|
||||
expect(await durationOf(new Blob([recordedChunk.buffer as ArrayBuffer]))).toBeNull();
|
||||
|
||||
vi.setSystemTime(Date.now() + 9_000);
|
||||
await act(async () => {
|
||||
harness.result.current.actions.stopRecording();
|
||||
});
|
||||
|
||||
expect(await durationOf(harness.result.current.actions.audioBlob)).toBeCloseTo(9_000, 0);
|
||||
});
|
||||
|
||||
it('does the same for a voice reply', async () => {
|
||||
const harness = renderActions();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.startReplyRecording();
|
||||
});
|
||||
vi.setSystemTime(Date.now() + 17_000);
|
||||
await act(async () => {
|
||||
harness.result.current.actions.stopReplyRecording();
|
||||
});
|
||||
|
||||
expect(harness.result.current.actions.replyRecordingTime).toBeCloseTo(17, 1);
|
||||
expect(await durationOf(harness.result.current.actions.replyAudioBlob)).toBeCloseTo(17_000, 0);
|
||||
});
|
||||
|
||||
it('leaves a non-webm recording untouched', async () => {
|
||||
recordedChunk = new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]); // MP4 'ftyp'
|
||||
const originalIsTypeSupported = FakeMediaRecorder.isTypeSupported;
|
||||
FakeMediaRecorder.isTypeSupported = () => false;
|
||||
const harness = renderActions();
|
||||
|
||||
await act(async () => {
|
||||
await harness.result.current.actions.startRecording();
|
||||
});
|
||||
vi.setSystemTime(Date.now() + 4_000);
|
||||
await act(async () => {
|
||||
harness.result.current.actions.stopRecording();
|
||||
});
|
||||
|
||||
const blob = harness.result.current.actions.audioBlob!;
|
||||
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(recordedChunk);
|
||||
FakeMediaRecorder.isTypeSupported = originalIsTypeSupported;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Builds WebM bytes in the shapes MediaRecorder emits them. The two browsers
|
||||
* differ in ways this code has to survive:
|
||||
*
|
||||
* Chrome writes no SeekHead and no Duration at all, with compact 1-byte
|
||||
* element sizes.
|
||||
* Firefox writes an empty SeekHead, a Duration reserved as 0.0 that it never
|
||||
* fills in, and 8-byte element sizes.
|
||||
*
|
||||
* Nothing here is a real audio stream; these tests only care about the
|
||||
* container fields a player reads to learn how long a recording is.
|
||||
*/
|
||||
|
||||
const UNKNOWN_SIZE = [0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
|
||||
const INFO_ID = [0x15, 0x49, 0xa9, 0x66];
|
||||
const SEEK_HEAD_ID = [0x11, 0x4d, 0x9b, 0x74];
|
||||
|
||||
/** A Duration of 0.0 in an 8-byte float, the placeholder Firefox leaves behind. */
|
||||
export const DURATION_PLACEHOLDER = [0x44, 0x89, 0x88, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
||||
export function ebmlElement(id: number[], content: number[], sizeWidth = 1): number[] {
|
||||
if (sizeWidth === 1) {
|
||||
if (content.length >= 127) throw new Error('a 1-byte size holds at most 126 bytes');
|
||||
return [...id, 0x80 | content.length, ...content];
|
||||
}
|
||||
|
||||
// Division, not `>>`: JS shifts wrap at 32 bits and an 8-byte size needs 56.
|
||||
const size = Array.from({ length: sizeWidth }, (_, i) =>
|
||||
i === 0
|
||||
? 0x100 >> sizeWidth
|
||||
: Math.floor(content.length / 2 ** (8 * (sizeWidth - 1 - i))) & 0xff
|
||||
);
|
||||
return [...id, ...size, ...content];
|
||||
}
|
||||
|
||||
export function timecodeScaleElement(nanoseconds: number): number[] {
|
||||
return ebmlElement(
|
||||
[0x2a, 0xd7, 0xb1],
|
||||
[(nanoseconds >> 16) & 0xff, (nanoseconds >> 8) & 0xff, nanoseconds & 0xff]
|
||||
);
|
||||
}
|
||||
|
||||
export const CLUSTER_BYTES = ebmlElement([0x1f, 0x43, 0xb6, 0x75], [0xe7, 0x81, 0x00]);
|
||||
|
||||
export function buildLiveWebm({
|
||||
info = timecodeScaleElement(1_000_000),
|
||||
seekHead = 'none',
|
||||
sizeWidth = 1,
|
||||
}: {
|
||||
info?: number[];
|
||||
seekHead?: 'none' | 'empty' | 'entries';
|
||||
sizeWidth?: number;
|
||||
} = {}): Uint8Array {
|
||||
const header = ebmlElement([0x1a, 0x45, 0xdf, 0xa3], [0x42, 0x86, 0x81, 0x01]);
|
||||
const seekHeadBytes =
|
||||
seekHead === 'none'
|
||||
? []
|
||||
: ebmlElement(SEEK_HEAD_ID, seekHead === 'empty' ? [] : [0x53, 0xac, 0x81, 0xa1], sizeWidth);
|
||||
const segmentBody = [
|
||||
...seekHeadBytes,
|
||||
...ebmlElement(INFO_ID, info, sizeWidth),
|
||||
...CLUSTER_BYTES,
|
||||
];
|
||||
return new Uint8Array([...header, 0x18, 0x53, 0x80, 0x67, ...UNKNOWN_SIZE, ...segmentBody]);
|
||||
}
|
||||
|
||||
/** Walks the file the way a player would and pulls Segment > Info > Duration. */
|
||||
export function readWebmDuration(bytes: Uint8Array): number | null {
|
||||
for (let i = 0; i < bytes.length - 4; i++) {
|
||||
if (!INFO_ID.every((byte, offset) => bytes[i + offset] === byte)) continue;
|
||||
|
||||
const sizeWidth = 8 - Math.floor(Math.log2(bytes[i + 4]));
|
||||
const infoStart = i + 4 + sizeWidth;
|
||||
let infoSize = bytes[i + 4] & (0xff >> sizeWidth);
|
||||
for (let k = 1; k < sizeWidth; k++) infoSize = infoSize * 0x100 + bytes[i + 4 + k];
|
||||
|
||||
for (let j = infoStart; j < infoStart + infoSize - 1; j++) {
|
||||
if (bytes[j] === 0x44 && bytes[j + 1] === 0x89) {
|
||||
const width = bytes[j + 2] & 0x7f;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
return width === 4 ? view.getFloat32(j + 3) : view.getFloat64(j + 3);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// The fixtures here are WebM files in the shape MediaRecorder emits them (see
|
||||
// tests/helpers/webm-fixture.ts). The assertions parse the patched bytes back,
|
||||
// because the failure mode that matters is a file that still opens but reports
|
||||
// the wrong length.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { injectWebmDuration } from '@/lib/webm-duration';
|
||||
import {
|
||||
CLUSTER_BYTES,
|
||||
DURATION_PLACEHOLDER,
|
||||
buildLiveWebm,
|
||||
ebmlElement,
|
||||
readWebmDuration,
|
||||
timecodeScaleElement,
|
||||
} from '../../helpers/webm-fixture';
|
||||
|
||||
describe('injectWebmDuration', () => {
|
||||
it('adds a Duration to a live-mode recording that has none', () => {
|
||||
const source = buildLiveWebm();
|
||||
expect(readWebmDuration(source)).toBeNull();
|
||||
|
||||
const patched = injectWebmDuration(source, 23_400);
|
||||
|
||||
expect(patched).not.toBeNull();
|
||||
expect(readWebmDuration(patched!)).toBeCloseTo(23_400, 3);
|
||||
expect(patched!.length).toBe(source.length + 11);
|
||||
});
|
||||
|
||||
it('expresses the duration in timecode ticks, not milliseconds', () => {
|
||||
// A 100us scale means one tick is a tenth of a millisecond.
|
||||
const source = buildLiveWebm({ info: timecodeScaleElement(100_000) });
|
||||
|
||||
const patched = injectWebmDuration(source, 5_000);
|
||||
|
||||
expect(readWebmDuration(patched!)).toBeCloseTo(50_000, 3);
|
||||
});
|
||||
|
||||
it('keeps the trailing clusters intact', () => {
|
||||
const patched = injectWebmDuration(buildLiveWebm(), 1_000)!;
|
||||
|
||||
expect(Array.from(patched.subarray(patched.length - CLUSTER_BYTES.length))).toEqual(
|
||||
CLUSTER_BYTES
|
||||
);
|
||||
});
|
||||
|
||||
it('overwrites a Duration that is already there', () => {
|
||||
const existing = [0x44, 0x89, 0x88, 0x40, 0x59, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // 100.0
|
||||
const source = buildLiveWebm({ info: [...timecodeScaleElement(1_000_000), ...existing] });
|
||||
expect(readWebmDuration(source)).toBeCloseTo(100, 3);
|
||||
|
||||
const patched = injectWebmDuration(source, 8_250)!;
|
||||
|
||||
expect(patched.length).toBe(source.length);
|
||||
expect(readWebmDuration(patched)).toBeCloseTo(8_250, 3);
|
||||
});
|
||||
|
||||
// Firefox writes an empty SeekHead, a Duration reserved as 0.0, and 8-byte
|
||||
// element sizes. Its recordings were the ones still playing past their end.
|
||||
it('fills in the duration Firefox reserves and never writes', () => {
|
||||
const source = buildLiveWebm({
|
||||
info: [...timecodeScaleElement(1_000_000), ...DURATION_PLACEHOLDER],
|
||||
seekHead: 'empty',
|
||||
sizeWidth: 8,
|
||||
});
|
||||
expect(readWebmDuration(source)).toBe(0);
|
||||
|
||||
const patched = injectWebmDuration(source, 9_500)!;
|
||||
|
||||
expect(patched.length).toBe(source.length);
|
||||
expect(readWebmDuration(patched)).toBeCloseTo(9_500, 3);
|
||||
});
|
||||
|
||||
it('splices into a file whose SeekHead is empty, because it holds no offsets', () => {
|
||||
const source = buildLiveWebm({ seekHead: 'empty' });
|
||||
|
||||
const patched = injectWebmDuration(source, 3_000)!;
|
||||
|
||||
expect(patched.length).toBe(source.length + 11);
|
||||
expect(readWebmDuration(patched)).toBeCloseTo(3_000, 3);
|
||||
});
|
||||
|
||||
it('leaves a SeekHead that has entries alone rather than shifting its offsets', () => {
|
||||
const source = buildLiveWebm({ seekHead: 'entries' });
|
||||
|
||||
expect(injectWebmDuration(source, 1_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('still fills in a reserved Duration when the SeekHead has entries', () => {
|
||||
const source = buildLiveWebm({
|
||||
info: [...timecodeScaleElement(1_000_000), ...DURATION_PLACEHOLDER],
|
||||
seekHead: 'entries',
|
||||
});
|
||||
|
||||
// Overwriting in place moves no bytes, so the stored offsets stay correct.
|
||||
expect(readWebmDuration(injectWebmDuration(source, 6_000)!)).toBeCloseTo(6_000, 3);
|
||||
});
|
||||
|
||||
it('defaults to a 1ms timecode scale when Info does not declare one', () => {
|
||||
const source = buildLiveWebm({ info: ebmlElement([0x73, 0xa4], [0x01, 0x02]) }); // SegmentUID
|
||||
|
||||
expect(readWebmDuration(injectWebmDuration(source, 12_000)!)).toBeCloseTo(12_000, 3);
|
||||
});
|
||||
|
||||
it('refuses durations that are not usable', () => {
|
||||
const source = buildLiveWebm();
|
||||
|
||||
expect(injectWebmDuration(source, 0)).toBeNull();
|
||||
expect(injectWebmDuration(source, -5)).toBeNull();
|
||||
expect(injectWebmDuration(source, Number.POSITIVE_INFINITY)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for bytes that are not a WebM segment', () => {
|
||||
expect(injectWebmDuration(new Uint8Array([0x00, 0x01, 0x02, 0x03]), 1_000)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user