mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-10-02 02:27:44 +00:00
revert: undo PRs 625 and 626
This commit is contained in:
@@ -4,6 +4,7 @@ import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
SourceAudioTrackMeta,
|
||||
SourceAudioTrackSettings,
|
||||
SourceAudioTrackWithPeaks,
|
||||
} from "@/components/video-editor/audio/audioTypes";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
@@ -25,10 +26,6 @@ import { calculateTimelineScale } from "./core/time";
|
||||
import { useTimelineAudioPeaks } from "./hooks/useTimelineAudioPeaks";
|
||||
import { useTimelineEditorRuntime } from "./hooks/useTimelineEditorRuntime";
|
||||
import { useTimelineRange } from "./hooks/useTimelineRange";
|
||||
import {
|
||||
buildSourceSidecarPathCandidates,
|
||||
buildTimelineSourceAudioTracks,
|
||||
} from "./sourceAudioTracks";
|
||||
|
||||
export interface TimelineEditorProps {
|
||||
videoDuration: number;
|
||||
@@ -93,6 +90,16 @@ function extractLocalPathFromMediaServerUrl(input: string | null | undefined): s
|
||||
}
|
||||
}
|
||||
|
||||
function buildSourceSidecarPath(source: string, suffix: "mic" | "system"): string {
|
||||
const normalized = source.replace(/\\/g, "/");
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
const dir = lastSlash >= 0 ? normalized.slice(0, lastSlash + 1) : "";
|
||||
const fileName = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
|
||||
const dotIndex = fileName.lastIndexOf(".");
|
||||
const baseName = dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
|
||||
return `${dir}${baseName}.${suffix}.wav`;
|
||||
}
|
||||
|
||||
export interface TimelineEditorHandle {
|
||||
addZoom: () => void;
|
||||
suggestZooms: () => void;
|
||||
@@ -227,8 +234,12 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
return { previewSpans, hiddenZoomIds };
|
||||
}, [clipRegions, liveSpanPreviewById, zoomRegions]);
|
||||
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
|
||||
const { peaks: sourceAudioPeaks, loading: sourceAudioLoading } =
|
||||
useTimelineAudioPeaks(videoPath);
|
||||
const { peaks: sourceAudioPeaks, loading: sourceAudioLoading } = useTimelineAudioPeaks(
|
||||
videoPath,
|
||||
{
|
||||
enableSourceSidecarFallback: true,
|
||||
},
|
||||
);
|
||||
const localSourcePath = useMemo(() => {
|
||||
if (!videoPath) return null;
|
||||
return (
|
||||
@@ -236,44 +247,45 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
(/^file:\/\//i.test(videoPath) ? fromFileUrl(videoPath) : videoPath)
|
||||
);
|
||||
}, [videoPath]);
|
||||
const micSidecarPaths = useMemo(
|
||||
() => (localSourcePath ? buildSourceSidecarPathCandidates(localSourcePath, "mic") : []),
|
||||
const micSidecarPath = useMemo(
|
||||
() => (localSourcePath ? buildSourceSidecarPath(localSourcePath, "mic") : null),
|
||||
[localSourcePath],
|
||||
);
|
||||
const micSidecarFallbackPaths = useMemo(() => micSidecarPaths.slice(1), [micSidecarPaths]);
|
||||
const systemSidecarPaths = useMemo(
|
||||
() =>
|
||||
localSourcePath ? buildSourceSidecarPathCandidates(localSourcePath, "system") : [],
|
||||
const systemSidecarPath = useMemo(
|
||||
() => (localSourcePath ? buildSourceSidecarPath(localSourcePath, "system") : null),
|
||||
[localSourcePath],
|
||||
);
|
||||
const systemSidecarFallbackPaths = useMemo(
|
||||
() => systemSidecarPaths.slice(1),
|
||||
[systemSidecarPaths],
|
||||
);
|
||||
const { peaks: micSidecarPeaks, loading: micSidecarLoading } = useTimelineAudioPeaks(
|
||||
micSidecarPaths[0] ?? null,
|
||||
{ fallbackResources: micSidecarFallbackPaths },
|
||||
);
|
||||
const { peaks: systemSidecarPeaks, loading: systemSidecarLoading } = useTimelineAudioPeaks(
|
||||
systemSidecarPaths[0] ?? null,
|
||||
{
|
||||
fallbackResources: systemSidecarFallbackPaths,
|
||||
},
|
||||
);
|
||||
const sourceAudioTracks = useMemo(
|
||||
() =>
|
||||
buildTimelineSourceAudioTracks({
|
||||
sourceAudioPeaks,
|
||||
micSidecarPeaks,
|
||||
systemSidecarPeaks,
|
||||
labels: {
|
||||
system: t("audio.systemLabel", "Source System"),
|
||||
mic: t("audio.micLabel", "Source Mic"),
|
||||
mixed: t("audio.mixedLabel", "Source"),
|
||||
},
|
||||
}),
|
||||
[micSidecarPeaks, sourceAudioPeaks, systemSidecarPeaks, t],
|
||||
);
|
||||
const { peaks: micSidecarPeaks, loading: micSidecarLoading } =
|
||||
useTimelineAudioPeaks(micSidecarPath);
|
||||
const { peaks: systemSidecarPeaks, loading: systemSidecarLoading } =
|
||||
useTimelineAudioPeaks(systemSidecarPath);
|
||||
const sourceAudioTracks = useMemo<SourceAudioTrackWithPeaks[]>(() => {
|
||||
if (systemSidecarPeaks || micSidecarPeaks) {
|
||||
const tracks: SourceAudioTrackWithPeaks[] = [];
|
||||
if (systemSidecarPeaks)
|
||||
tracks.push({
|
||||
id: "system",
|
||||
label: t("audio.systemLabel", "Source System"),
|
||||
peaks: systemSidecarPeaks,
|
||||
});
|
||||
if (micSidecarPeaks)
|
||||
tracks.push({
|
||||
id: "mic",
|
||||
label: t("audio.micLabel", "Source Mic"),
|
||||
peaks: micSidecarPeaks,
|
||||
});
|
||||
return tracks;
|
||||
}
|
||||
return sourceAudioPeaks
|
||||
? [
|
||||
{
|
||||
id: "mixed",
|
||||
label: t("audio.mixedLabel", "Source"),
|
||||
peaks: sourceAudioPeaks,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}, [micSidecarPeaks, sourceAudioPeaks, systemSidecarPeaks, t]);
|
||||
|
||||
const isLoading = useMemo(() => {
|
||||
// If we are still actively trying to load audio peaks (main or sidecars)
|
||||
|
||||
@@ -5,8 +5,6 @@ import { fromFileUrl } from "../../projectPersistence";
|
||||
import { WAVEFORM_DEFAULT_PEAK_COUNT } from "../core/constants";
|
||||
import type { AudioPeaksData } from "../core/timelineTypes";
|
||||
|
||||
const EMPTY_FALLBACK_RESOURCES: string[] = [];
|
||||
|
||||
function buildSidecarAudioCandidates(sourcePath: string): string[] {
|
||||
const normalized = sourcePath.replace(/\\/g, "/");
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
@@ -20,8 +18,6 @@ function buildSidecarAudioCandidates(sourcePath: string): string[] {
|
||||
`${dir}${baseName}.mic.wav`,
|
||||
`${dir}${baseName}.system.m4a`,
|
||||
`${dir}${baseName}.mic.m4a`,
|
||||
`${dir}${baseName}.system.webm`,
|
||||
`${dir}${baseName}.mic.webm`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -41,7 +37,6 @@ function extractLocalPathFromMediaServerUrl(input: string): string | null {
|
||||
|
||||
interface TimelineAudioPeaksOptions {
|
||||
enableSourceSidecarFallback?: boolean;
|
||||
fallbackResources?: string[];
|
||||
peakCount?: number;
|
||||
}
|
||||
|
||||
@@ -58,7 +53,6 @@ export function useTimelineAudioPeaks(
|
||||
const [loading, setLoading] = useState(false);
|
||||
const sourceRef = useRef(mediaResource);
|
||||
const enableSourceSidecarFallback = options.enableSourceSidecarFallback ?? false;
|
||||
const fallbackResources = options.fallbackResources ?? EMPTY_FALLBACK_RESOURCES;
|
||||
const peakCount = options.peakCount ?? WAVEFORM_DEFAULT_PEAK_COUNT;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,29 +83,25 @@ export function useTimelineAudioPeaks(
|
||||
// fallthrough
|
||||
}
|
||||
|
||||
if (!enableSourceSidecarFallback && fallbackResources.length === 0) {
|
||||
if (!enableSourceSidecarFallback) {
|
||||
if (!cancelled && sourceRef.current === mediaResource) {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let sourceSidecarCandidates: string[] = [];
|
||||
if (enableSourceSidecarFallback) {
|
||||
const localPathFromServer = extractLocalPathFromMediaServerUrl(mediaResource);
|
||||
const localSourcePath =
|
||||
localPathFromServer ||
|
||||
(/^file:\/\//i.test(mediaResource)
|
||||
? fromFileUrl(mediaResource)
|
||||
: mediaResource);
|
||||
if (localSourcePath) {
|
||||
sourceSidecarCandidates = buildSidecarAudioCandidates(localSourcePath);
|
||||
const localPathFromServer = extractLocalPathFromMediaServerUrl(mediaResource);
|
||||
const localSourcePath =
|
||||
localPathFromServer ||
|
||||
(/^file:\/\//i.test(mediaResource) ? fromFileUrl(mediaResource) : mediaResource);
|
||||
if (!localSourcePath) {
|
||||
if (!cancelled && sourceRef.current === mediaResource) {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = Array.from(
|
||||
new Set([...fallbackResources, ...sourceSidecarCandidates]),
|
||||
);
|
||||
const candidates = buildSidecarAudioCandidates(localSourcePath);
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const result = await tryGenerate(candidate);
|
||||
@@ -135,7 +125,7 @@ export function useTimelineAudioPeaks(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mediaResource, enableSourceSidecarFallback, fallbackResources, peakCount]);
|
||||
}, [mediaResource, enableSourceSidecarFallback, peakCount]);
|
||||
|
||||
return { peaks, loading };
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AudioPeaksData } from "./core/timelineTypes";
|
||||
import {
|
||||
buildSourceSidecarPathCandidates,
|
||||
buildTimelineSourceAudioTracks,
|
||||
} from "./sourceAudioTracks";
|
||||
|
||||
function peaks(id: number): AudioPeaksData {
|
||||
return {
|
||||
durationMs: 1000,
|
||||
peaks: new Float32Array([id]),
|
||||
};
|
||||
}
|
||||
|
||||
const labels = {
|
||||
system: "Source System",
|
||||
mic: "Source Mic",
|
||||
mixed: "Source",
|
||||
};
|
||||
|
||||
describe("timeline source audio tracks", () => {
|
||||
it("builds candidates for Windows and macOS sidecar containers", () => {
|
||||
expect(buildSourceSidecarPathCandidates("C:\\Recordly\\recording-1.mp4", "mic")).toEqual([
|
||||
"C:/Recordly/recording-1.mic.wav",
|
||||
"C:/Recordly/recording-1.mic.m4a",
|
||||
"C:/Recordly/recording-1.mic.webm",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps embedded system audio controllable when mic is a sidecar", () => {
|
||||
const source = peaks(1);
|
||||
const mic = peaks(2);
|
||||
|
||||
expect(
|
||||
buildTimelineSourceAudioTracks({
|
||||
sourceAudioPeaks: source,
|
||||
micSidecarPeaks: mic,
|
||||
systemSidecarPeaks: null,
|
||||
labels,
|
||||
}),
|
||||
).toEqual([
|
||||
{ id: "system", label: "Source System", peaks: source },
|
||||
{ id: "mic", label: "Source Mic", peaks: mic },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not invent a system track when only the mic sidecar exists", () => {
|
||||
const mic = peaks(2);
|
||||
|
||||
expect(
|
||||
buildTimelineSourceAudioTracks({
|
||||
sourceAudioPeaks: null,
|
||||
micSidecarPeaks: mic,
|
||||
systemSidecarPeaks: null,
|
||||
labels,
|
||||
}),
|
||||
).toEqual([{ id: "mic", label: "Source Mic", peaks: mic }]);
|
||||
});
|
||||
|
||||
it("uses dedicated sidecars over the embedded track when both source tracks exist", () => {
|
||||
const source = peaks(1);
|
||||
const system = peaks(2);
|
||||
const mic = peaks(3);
|
||||
|
||||
expect(
|
||||
buildTimelineSourceAudioTracks({
|
||||
sourceAudioPeaks: source,
|
||||
micSidecarPeaks: mic,
|
||||
systemSidecarPeaks: system,
|
||||
labels,
|
||||
}),
|
||||
).toEqual([
|
||||
{ id: "system", label: "Source System", peaks: system },
|
||||
{ id: "mic", label: "Source Mic", peaks: mic },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to one mixed source track when no dedicated sidecar exists", () => {
|
||||
const source = peaks(1);
|
||||
|
||||
expect(
|
||||
buildTimelineSourceAudioTracks({
|
||||
sourceAudioPeaks: source,
|
||||
micSidecarPeaks: null,
|
||||
systemSidecarPeaks: null,
|
||||
labels,
|
||||
}),
|
||||
).toEqual([{ id: "mixed", label: "Source", peaks: source }]);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { SourceAudioTrackWithPeaks } from "@/components/video-editor/audio/audioTypes";
|
||||
import type { AudioPeaksData } from "./core/timelineTypes";
|
||||
|
||||
const SOURCE_SIDECAR_EXTENSIONS = [".wav", ".m4a", ".webm"] as const;
|
||||
|
||||
export function buildSourceSidecarPathCandidates(
|
||||
source: string,
|
||||
suffix: "mic" | "system",
|
||||
): string[] {
|
||||
const normalized = source.replace(/\\/g, "/");
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
const dir = lastSlash >= 0 ? normalized.slice(0, lastSlash + 1) : "";
|
||||
const fileName = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
|
||||
const dotIndex = fileName.lastIndexOf(".");
|
||||
const baseName = dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
|
||||
return SOURCE_SIDECAR_EXTENSIONS.map((extension) => `${dir}${baseName}.${suffix}${extension}`);
|
||||
}
|
||||
|
||||
export function buildTimelineSourceAudioTracks({
|
||||
sourceAudioPeaks,
|
||||
micSidecarPeaks,
|
||||
systemSidecarPeaks,
|
||||
labels,
|
||||
}: {
|
||||
sourceAudioPeaks: AudioPeaksData | null;
|
||||
micSidecarPeaks: AudioPeaksData | null;
|
||||
systemSidecarPeaks: AudioPeaksData | null;
|
||||
labels: {
|
||||
system: string;
|
||||
mic: string;
|
||||
mixed: string;
|
||||
};
|
||||
}): SourceAudioTrackWithPeaks[] {
|
||||
if (systemSidecarPeaks || micSidecarPeaks) {
|
||||
const tracks: SourceAudioTrackWithPeaks[] = [];
|
||||
if (systemSidecarPeaks) {
|
||||
tracks.push({
|
||||
id: "system",
|
||||
label: labels.system,
|
||||
peaks: systemSidecarPeaks,
|
||||
});
|
||||
} else if (micSidecarPeaks && sourceAudioPeaks) {
|
||||
tracks.push({
|
||||
id: "system",
|
||||
label: labels.system,
|
||||
peaks: sourceAudioPeaks,
|
||||
});
|
||||
}
|
||||
if (micSidecarPeaks) {
|
||||
tracks.push({
|
||||
id: "mic",
|
||||
label: labels.mic,
|
||||
peaks: micSidecarPeaks,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
return sourceAudioPeaks
|
||||
? [
|
||||
{
|
||||
id: "mixed",
|
||||
label: labels.mixed,
|
||||
peaks: sourceAudioPeaks,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
normalizeBrowserMicrophoneProfile,
|
||||
resolveBrowserCaptureCursorPolicy,
|
||||
resolveLinuxPortalCursorPresentation,
|
||||
shouldLockHudDuringDisplaySelection,
|
||||
shouldUseLinuxPortalCapture,
|
||||
shouldUseNativeWindowsCaptureForSource,
|
||||
} from "./useScreenRecorder";
|
||||
@@ -247,32 +246,6 @@ describe("shouldUseLinuxPortalCapture", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldLockHudDuringDisplaySelection", () => {
|
||||
it("locks HUD fallback resizing while Linux portal selection is active", () => {
|
||||
expect(
|
||||
shouldLockHudDuringDisplaySelection({
|
||||
platform: "linux",
|
||||
useLinuxPortal: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps non-portal capture flows interactive", () => {
|
||||
expect(
|
||||
shouldLockHudDuringDisplaySelection({
|
||||
platform: "linux",
|
||||
useLinuxPortal: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldLockHudDuringDisplaySelection({
|
||||
platform: "win32",
|
||||
useLinuxPortal: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScreenCaptureCursorSetting", () => {
|
||||
it("normalizes only supported screen-capture cursor settings", () => {
|
||||
expect(getScreenCaptureCursorSetting({ cursor: "motion" } as MediaTrackSettings)).toBe(
|
||||
|
||||
@@ -145,16 +145,6 @@ export function shouldUseLinuxPortalCapture({
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldLockHudDuringDisplaySelection({
|
||||
platform,
|
||||
useLinuxPortal,
|
||||
}: {
|
||||
platform?: string;
|
||||
useLinuxPortal: boolean;
|
||||
}) {
|
||||
return platform === "linux" && useLinuxPortal;
|
||||
}
|
||||
|
||||
type UseScreenRecorderReturn = {
|
||||
recording: boolean;
|
||||
paused: boolean;
|
||||
@@ -1439,16 +1429,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
let hudSourceSelectionActive = false;
|
||||
const setHudSourceSelectionActive = (active: boolean) => {
|
||||
if (hudSourceSelectionActive === active) {
|
||||
return;
|
||||
}
|
||||
|
||||
hudSourceSelectionActive = active;
|
||||
window.electronAPI?.hudOverlaySetSourceSelectionActive?.(active);
|
||||
};
|
||||
|
||||
hasPromptedForReselect.current = false;
|
||||
startInFlight.current = true;
|
||||
setStarting(true);
|
||||
@@ -1648,14 +1628,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
|
||||
setRecording(true);
|
||||
try {
|
||||
await window.electronAPI?.setRecordingState(true);
|
||||
} catch (stateError) {
|
||||
console.warn(
|
||||
"Failed to notify main process that native recording started:",
|
||||
stateError,
|
||||
);
|
||||
}
|
||||
window.electronAPI?.setRecordingState(true);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1703,9 +1676,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
browserCaptureSourceId: browserCaptureSource.id,
|
||||
selectedSourceId: selectedSource.id,
|
||||
});
|
||||
if (shouldLockHudDuringDisplaySelection({ platform, useLinuxPortal })) {
|
||||
setHudSourceSelectionActive(true);
|
||||
}
|
||||
const browserScreenVideoConstraints = {
|
||||
mandatory: {
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
@@ -2020,11 +1990,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt;
|
||||
recorder.start(RECORDER_TIMESLICE_MS);
|
||||
setRecording(true);
|
||||
try {
|
||||
await window.electronAPI?.setRecordingState(true);
|
||||
} catch (stateError) {
|
||||
console.warn("Failed to notify main process that recording started:", stateError);
|
||||
}
|
||||
window.electronAPI?.setRecordingState(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to start recording:", error);
|
||||
alert(
|
||||
@@ -2042,7 +2008,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
await stopWebcamRecorder();
|
||||
}
|
||||
} finally {
|
||||
setHudSourceSelectionActive(false);
|
||||
startInFlight.current = false;
|
||||
setStarting(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AudioProcessor, softLimitOfflineMixPeaksInPlace } from "./audioEncoder";
|
||||
import { AudioProcessor } from "./audioEncoder";
|
||||
|
||||
type OfflineRenderTestHarness = AudioProcessor & {
|
||||
decodeAudioFromUrl(url: string): Promise<AudioBuffer | null>;
|
||||
@@ -26,32 +26,8 @@ type OfflineRenderTestHarness = AudioProcessor & {
|
||||
sourceAudioFallbackStartDelayMsByPath: Record<string, number> | undefined,
|
||||
muxer: unknown,
|
||||
): Promise<void>;
|
||||
renderChunked(
|
||||
prepared: {
|
||||
mainBufferEntry: null;
|
||||
companionEntries: [];
|
||||
regionEntries: [];
|
||||
mutedSourceOutputRangesSec: [];
|
||||
slices: [];
|
||||
outputDurationMs: number;
|
||||
numChannels: number;
|
||||
},
|
||||
totalOutputSec: number,
|
||||
onChunk: (
|
||||
rendered: AudioBuffer,
|
||||
outputOffsetSec: number,
|
||||
chunkIndex: number,
|
||||
) => Promise<void>,
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
function fakeAudioBuffer(channels: Float32Array[]): AudioBuffer {
|
||||
return {
|
||||
numberOfChannels: channels.length,
|
||||
getChannelData: (channel: number) => channels[channel],
|
||||
} as AudioBuffer;
|
||||
}
|
||||
|
||||
describe("AudioProcessor offline render preparation", () => {
|
||||
it("keeps embedded source audio separate from external companion sidecars", async () => {
|
||||
const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness;
|
||||
@@ -160,71 +136,4 @@ describe("AudioProcessor offline render preparation", () => {
|
||||
expect(loadAudioFileDemuxer).not.toHaveBeenCalled();
|
||||
expect(renderAndMuxOfflineAudio).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("soft-limits mixed peaks before encoding or WAV conversion", () => {
|
||||
const samples = new Float32Array([
|
||||
-1.6,
|
||||
-0.5,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
Number.NaN,
|
||||
0,
|
||||
0.5,
|
||||
0.95,
|
||||
Number.POSITIVE_INFINITY,
|
||||
1.6,
|
||||
]);
|
||||
const changed = softLimitOfflineMixPeaksInPlace(fakeAudioBuffer([samples]));
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(samples[0]).toBeGreaterThanOrEqual(-0.986);
|
||||
expect(samples[1]).toBe(-0.5);
|
||||
expect(samples[2]).toBe(0);
|
||||
expect(samples[3]).toBe(0);
|
||||
expect(samples[5]).toBe(0.5);
|
||||
expect(samples[6]).toBeLessThan(0.95);
|
||||
expect(samples[6]).toBeGreaterThan(0.9);
|
||||
expect(samples[7]).toBe(0);
|
||||
expect(samples[8]).toBeLessThanOrEqual(0.986);
|
||||
});
|
||||
|
||||
it("runs the offline mix limiter for every rendered chunk", async () => {
|
||||
const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness;
|
||||
const renderedSamples = new Float32Array([1.4]);
|
||||
const renderedBuffer = fakeAudioBuffer([renderedSamples]);
|
||||
const originalOfflineAudioContext = globalThis.OfflineAudioContext;
|
||||
(
|
||||
globalThis as unknown as { OfflineAudioContext: typeof OfflineAudioContext }
|
||||
).OfflineAudioContext = class {
|
||||
constructor() {}
|
||||
|
||||
startRendering() {
|
||||
return Promise.resolve(renderedBuffer);
|
||||
}
|
||||
} as unknown as typeof OfflineAudioContext;
|
||||
|
||||
try {
|
||||
let observedPeak = Number.POSITIVE_INFINITY;
|
||||
await processor.renderChunked(
|
||||
{
|
||||
mainBufferEntry: null,
|
||||
companionEntries: [],
|
||||
regionEntries: [],
|
||||
mutedSourceOutputRangesSec: [],
|
||||
slices: [],
|
||||
outputDurationMs: 100,
|
||||
numChannels: 1,
|
||||
},
|
||||
0.1,
|
||||
async (rendered) => {
|
||||
observedPeak = rendered.getChannelData(0)[0] ?? 0;
|
||||
},
|
||||
);
|
||||
|
||||
expect(observedPeak).toBeLessThanOrEqual(0.986);
|
||||
} finally {
|
||||
(
|
||||
globalThis as unknown as { OfflineAudioContext: typeof OfflineAudioContext }
|
||||
).OfflineAudioContext = originalOfflineAudioContext;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,44 +21,6 @@ const MP4_AUDIO_CODEC = "mp4a.40.2";
|
||||
const OFFLINE_AUDIO_SAMPLE_RATE = 48_000;
|
||||
const OFFLINE_ENCODE_CHUNK_FRAMES = 1024;
|
||||
const OFFLINE_CHUNK_DURATION_SEC = 30;
|
||||
const OFFLINE_MIX_SOFT_LIMITER_THRESHOLD = 0.9;
|
||||
const OFFLINE_MIX_SOFT_LIMITER_CEILING = 0.985;
|
||||
|
||||
function softLimitSample(sample: number): number {
|
||||
const magnitude = Math.abs(sample);
|
||||
if (magnitude <= OFFLINE_MIX_SOFT_LIMITER_THRESHOLD) {
|
||||
return sample;
|
||||
}
|
||||
|
||||
const sign = sample < 0 ? -1 : 1;
|
||||
const kneeRange = 1 - OFFLINE_MIX_SOFT_LIMITER_THRESHOLD;
|
||||
const limitedMagnitude =
|
||||
OFFLINE_MIX_SOFT_LIMITER_THRESHOLD +
|
||||
kneeRange * Math.tanh((magnitude - OFFLINE_MIX_SOFT_LIMITER_THRESHOLD) / kneeRange);
|
||||
return sign * Math.min(OFFLINE_MIX_SOFT_LIMITER_CEILING, limitedMagnitude);
|
||||
}
|
||||
|
||||
export function softLimitOfflineMixPeaksInPlace(buffer: AudioBuffer): boolean {
|
||||
let changed = false;
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) {
|
||||
const data = buffer.getChannelData(channel);
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
const sample = data[index];
|
||||
if (!Number.isFinite(sample)) {
|
||||
data[index] = 0;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const limited = softLimitSample(sample);
|
||||
if (limited !== sample) {
|
||||
data[index] = limited;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function resolveSourceTrackGain(
|
||||
sourceAudioTrackSettings: SourceAudioTrackSettings | undefined,
|
||||
@@ -985,7 +947,6 @@ export class AudioProcessor {
|
||||
|
||||
const rendered = await offlineCtx.startRendering();
|
||||
if (this.cancelled) break;
|
||||
softLimitOfflineMixPeaksInPlace(rendered);
|
||||
|
||||
await onChunk(rendered, outputOffsetSec, i);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user