fix all audios issues it seems finally. with WebAudio API instead of html audio.

This commit is contained in:
Alan Trebugeais
2026-05-09 19:14:28 +02:00
parent d4fe5d72a1
commit 4d5a7ceb6d
13 changed files with 352 additions and 129 deletions
+22 -8
View File
@@ -481,7 +481,9 @@ interface SettingsPanelProps {
onClipDelete?: (id: string) => void;
selectedAudioId?: string | null;
selectedAudioVolume?: number | null;
selectedAudioNormalize?: boolean | null;
onAudioVolumeChange?: (volume: number) => void;
onAudioNormalizeChange?: (normalize: boolean) => void;
onAudioDelete?: (id: string) => void;
shadowIntensity?: number;
onShadowChange?: (intensity: number) => void;
@@ -881,7 +883,9 @@ export function SettingsPanel({
onClipDelete,
selectedAudioId,
selectedAudioVolume,
selectedAudioNormalize,
onAudioVolumeChange,
onAudioNormalizeChange,
onAudioDelete,
shadowIntensity = 0.67,
onShadowChange,
@@ -3049,16 +3053,16 @@ export function SettingsPanel({
</section>
);
const audioSectionContent = (
<section className="flex flex-col gap-3">
const audioSectionContent = (
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<SectionLabel>{tSettings("audio.volumeTitle", "Audio")}</SectionLabel>
<span className="rounded-full bg-[#2563EB]/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[#2563EB]">
{Math.round((selectedAudioVolume ?? 1) * 100)}%
</span>
</div>
<SliderControl
label={tSettings("audio.volume", "Volume")}
<SliderControl
label={tSettings("audio.volume", "Volume")}
value={selectedAudioVolume ?? 1}
defaultValue={1}
min={0}
@@ -3066,10 +3070,20 @@ export function SettingsPanel({
step={0.01}
onChange={(v) => onAudioVolumeChange?.(v)}
formatValue={(v) => `${Math.round(v * 100)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
/>
</section>
);
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
/>
<div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
<span className="text-[10px] text-muted-foreground">
{tSettings("audio.normalize", "Normalize")}
</span>
<Switch
checked={Boolean(selectedAudioNormalize)}
onCheckedChange={(v) => onAudioNormalizeChange?.(v)}
className="data-[state=checked]:bg-[#2563EB] scale-75"
/>
</div>
</section>
);
const clipSectionContent = (
<section className="flex flex-col gap-2">
+44 -22
View File
@@ -3776,16 +3776,17 @@ export default function VideoEditor() {
}
}, []);
const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => {
const id = `audio-${nextAudioIdRef.current++}`;
const newRegion: AudioRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
audioPath,
volume: 1,
trackIndex,
};
const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => {
const id = `audio-${nextAudioIdRef.current++}`;
const newRegion: AudioRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
audioPath,
volume: 1,
normalize: false,
trackIndex,
};
setAudioRegions((prev) => [...prev, newRegion]);
setSelectedAudioId(id);
setSelectedZoomId(null);
@@ -3835,15 +3836,29 @@ export default function VideoEditor() {
[selectedAudioId],
);
const handleAudioDelete = useCallback(
(id: string) => {
const handleAudioDelete = useCallback(
(id: string) => {
setAudioRegions((prev) => prev.filter((region) => region.id !== id));
if (selectedAudioId === id) {
setSelectedAudioId(null);
}
},
[selectedAudioId],
);
[selectedAudioId],
);
const handleAudioNormalizeChange = useCallback(
(normalize: boolean) => {
if (!selectedAudioId) {
return;
}
setAudioRegions((prev) =>
prev.map((region) =>
region.id === selectedAudioId ? { ...region, normalize } : region,
),
);
},
[selectedAudioId],
);
const handleAnnotationAdded = useCallback((span: Span, trackIndex = 0) => {
const id = `annotation-${nextAnnotationIdRef.current++}`;
@@ -5761,14 +5776,21 @@ export default function VideoEditor() {
audio.onSelectedClipSourceAudioTrackNormalizeChange
}
selectedAudioId={selectedAudioId}
selectedAudioVolume={
selectedAudioId
? (audioRegions.find((r) => r.id === selectedAudioId)
?.volume ?? null)
: null
}
onAudioVolumeChange={handleAudioVolumeChange}
onAudioDelete={handleAudioDelete}
selectedAudioVolume={
selectedAudioId
? (audioRegions.find((r) => r.id === selectedAudioId)
?.volume ?? null)
: null
}
selectedAudioNormalize={
selectedAudioId
? (audioRegions.find((r) => r.id === selectedAudioId)
?.normalize ?? false)
: null
}
onAudioVolumeChange={handleAudioVolumeChange}
onAudioNormalizeChange={handleAudioNormalizeChange}
onAudioDelete={handleAudioDelete}
shadowIntensity={shadowIntensity}
onShadowChange={setShadowIntensity}
backgroundBlur={backgroundBlur}
@@ -1,9 +1,5 @@
import { getSourceTrackIdFromPath } from "@/lib/exporter/sourceTrackRoutingPolicy";
export const SOURCE_AUDIO_FALLBACK_TOAST_ID = "source-audio-fallback-error";
export const SOURCE_AUDIO_NORMALIZE_GAIN = 1.35;
export function getSourceTrackIdFromPath(audioPath: string): "mic" | "system" | "mixed" {
const normalized = audioPath.toLowerCase();
if (normalized.includes(".mic.")) return "mic";
if (normalized.includes(".system.")) return "system";
return "mixed";
}
export { getSourceTrackIdFromPath };
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { buildResolvedAudioPlan } from "@/lib/exporter/audioRoutingEngine";
import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource";
import {
clampMediaTimeToDuration,
@@ -40,6 +41,24 @@ export function useAudioPreviewSync({
getSourceTrackPreviewGain,
onSourceFallbackLoadError,
}: UseAudioPreviewSyncParams) {
const resolvedPlan = useMemo(
() =>
buildResolvedAudioPlan({
videoResource: null,
sourceAudioFallbackPaths: previewSourceAudioFallbackPaths,
audioRegions,
}),
[audioRegions, previewSourceAudioFallbackPaths],
);
const resolvedUserTracks = useMemo(
() => resolvedPlan.tracks.filter((track) => track.kind === "user"),
[resolvedPlan],
);
const resolvedSourceTracks = useMemo(
() => resolvedPlan.tracks.filter((track) => track.kind !== "user"),
[resolvedPlan],
);
const audioElementsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
const audioElementRevokersRef = useRef<Map<string, () => void>>(new Map());
const audioElementResourcesRef = useRef<Map<string, string>>(new Map());
@@ -84,7 +103,7 @@ export function useAudioPreviewSync({
useEffect(() => {
let cancelled = false;
const existing = audioElementsRef.current;
const currentIds = new Set(audioRegions.map((r) => r.id));
const currentIds = new Set(resolvedUserTracks.map((track) => track.id));
for (const [id, audio] of existing) {
if (!currentIds.has(id)) {
@@ -97,51 +116,51 @@ export function useAudioPreviewSync({
}
}
for (const region of audioRegions) {
let audio = existing.get(region.id);
for (const track of resolvedUserTracks) {
let audio = existing.get(track.id);
if (!audio) {
audio = new Audio();
audio.preload = "auto";
existing.set(region.id, audio);
existing.set(track.id, audio);
}
if (audioElementResourcesRef.current.get(region.id) !== region.audioPath) {
if (audioElementResourcesRef.current.get(track.id) !== track.sourceRef.path) {
audio.pause();
audio.src = "";
audioElementRevokersRef.current.get(region.id)?.();
audioElementRevokersRef.current.delete(region.id);
audioElementResourcesRef.current.set(region.id, region.audioPath);
audioElementRevokersRef.current.get(track.id)?.();
audioElementRevokersRef.current.delete(track.id);
audioElementResourcesRef.current.set(track.id, track.sourceRef.path);
void (async () => {
const resolved = await resolveMediaElementSource(region.audioPath);
const latestAudio = existing.get(region.id);
const resolved = await resolveMediaElementSource(track.sourceRef.path);
const latestAudio = existing.get(track.id);
if (
cancelled ||
latestAudio !== audio ||
audioElementResourcesRef.current.get(region.id) !== region.audioPath
audioElementResourcesRef.current.get(track.id) !== track.sourceRef.path
) {
resolved.revoke();
return;
}
audioElementRevokersRef.current.set(region.id, resolved.revoke);
audioElementRevokersRef.current.set(track.id, resolved.revoke);
latestAudio.src = resolved.src;
})();
}
audio.volume = Math.max(0, Math.min(1, region.volume * previewVolume));
audio.volume = Math.max(0, Math.min(1, track.gain * previewVolume));
}
return () => {
cancelled = true;
};
}, [audioRegions, previewVolume]);
}, [previewVolume, resolvedUserTracks]);
useEffect(() => {
let cancelled = false;
const existing = sourceAudioElementsRef.current;
const currentIds = new Set(previewSourceAudioFallbackPaths);
const currentIds = new Set(resolvedSourceTracks.map((track) => track.sourceRef.path));
for (const [id, audio] of existing) {
if (!currentIds.has(id)) {
@@ -158,7 +177,8 @@ export function useAudioPreviewSync({
}
}
for (const audioPath of previewSourceAudioFallbackPaths) {
for (const track of resolvedSourceTracks) {
const audioPath = track.sourceRef.path;
let audio = existing.get(audioPath);
if (!audio) {
audio = new Audio();
@@ -237,7 +257,7 @@ export function useAudioPreviewSync({
: Math.max(0, Math.min(1, previewVolume));
}
if (previewSourceAudioFallbackPaths.length === 0) {
if (resolvedSourceTracks.length === 0) {
lastSourceAudioSyncTimeRef.current = null;
}
@@ -248,7 +268,7 @@ export function useAudioPreviewSync({
getSourceTrackPreviewGain,
isCurrentClipMuted,
onSourceFallbackLoadError,
previewSourceAudioFallbackPaths,
resolvedSourceTracks,
previewVolume,
]);
@@ -303,15 +323,17 @@ export function useAudioPreviewSync({
);
const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
for (const region of audioRegions) {
const audio = audioElementsRef.current.get(region.id);
for (const track of resolvedUserTracks) {
const audio = audioElementsRef.current.get(track.id);
if (!audio) continue;
const isInRegion = currentTimeMs >= region.startMs && currentTimeMs < region.endMs;
const startMs = track.timelineBinding.startMs;
const endMs = track.timelineBinding.endMs;
const isInRegion = currentTimeMs >= startMs && currentTimeMs < endMs;
if (isPlaying && isInRegion) {
enablePitchPreservingPlayback(audio);
const audioOffset = (currentTimeMs - region.startMs) / 1000;
const audioOffset = (currentTimeMs - startMs) / 1000;
if (Math.abs(audio.currentTime - audioOffset) > 0.2) {
audio.currentTime = audioOffset;
}
@@ -330,10 +352,10 @@ export function useAudioPreviewSync({
audio.pause();
}
}
}, [audioRegions, timelineTime, effectiveSpeedRegions, isPlaying]);
}, [effectiveSpeedRegions, isPlaying, resolvedUserTracks, timelineTime]);
useEffect(() => {
if (previewSourceAudioFallbackPaths.length === 0) {
if (resolvedSourceTracks.length === 0) {
lastSourceAudioSyncTimeRef.current = null;
return;
}
@@ -418,12 +440,12 @@ export function useAudioPreviewSync({
isCurrentClipMuted,
isPlaying,
previewVolume,
previewSourceAudioFallbackPaths,
resolvedSourceTracks,
sourceAudioFallbackStartDelayMsByPath,
]);
useEffect(() => {
if (!isPlaying || previewSourceAudioFallbackPaths.length === 0) {
if (!isPlaying || resolvedSourceTracks.length === 0) {
return;
}
void ensureSourceAudioRunning().then(() => {
@@ -433,5 +455,5 @@ export function useAudioPreviewSync({
}
}
});
}, [isPlaying, previewSourceAudioFallbackPaths]);
}, [isPlaying, resolvedSourceTracks.length]);
}
@@ -24,6 +24,19 @@ export interface UseSourceAudioTrackSettingsResult {
onSelectedClipSourceAudioTrackNormalizeChange: (id: string, normalize: boolean) => void;
}
function isSameTrackMeta(left: SourceAudioTrackMeta, right: SourceAudioTrackMeta): boolean {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const leftTrack = left[index];
const rightTrack = right[index];
if (!leftTrack || !rightTrack) return false;
if (leftTrack.id !== rightTrack.id || leftTrack.label !== rightTrack.label) {
return false;
}
}
return true;
}
export function useSourceAudioTrackSettings({
selectedClipId,
activeClipId,
@@ -55,13 +68,31 @@ export function useSourceAudioTrackSettings({
}, [defaultSourceAudioTrackSettings, selectedClipId, sourceAudioTrackSettingsByClip]);
const onSourceAudioTracksMetaChange = useCallback((tracks: SourceAudioTrackMeta) => {
setSourceAudioTrackMeta(tracks);
setSourceAudioTrackMeta((prev) => (isSameTrackMeta(prev, tracks) ? prev : tracks));
setDefaultSourceAudioTrackSettings((prev) => {
const next: SourceAudioTrackSettings = {};
for (const track of tracks) {
next[track.id] = prev[track.id] ?? { volume: 1, normalize: false };
}
return next;
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length) {
return next;
}
for (const key of nextKeys) {
const prevSetting = prev[key];
const nextSetting = next[key];
if (!prevSetting || !nextSetting) {
return next;
}
if (
prevSetting.volume !== nextSetting.volume ||
prevSetting.normalize !== nextSetting.normalize
) {
return next;
}
}
return prev;
});
}, []);
@@ -654,16 +654,17 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
return {
id: region.id,
startMs,
endMs,
audioPath: typeof region.audioPath === "string" ? region.audioPath : "",
volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1,
trackIndex: isFiniteNumber(region.trackIndex)
? Math.max(0, Math.floor(region.trackIndex))
: 0,
};
return {
id: region.id,
startMs,
endMs,
audioPath: typeof region.audioPath === "string" ? region.audioPath : "",
volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1,
normalize: Boolean(region.normalize),
trackIndex: isFiniteNumber(region.trackIndex)
? Math.max(0, Math.floor(region.trackIndex))
: 0,
};
})
: [];
@@ -263,16 +263,18 @@ function AudioItemWithWaveform({
return { start: 0, end: duration };
}, [waveformSpan.end, waveformSpan.start]);
return (
<Item
id={item.id}
rowId={item.rowId}
span={span}
isSelected={isSelected}
onSelectId={onSelectAudio}
variant="audio"
waveformPeaks={peaks}
waveformSegmentSpan={normalizedWaveformSpan}
>
<Item
id={item.id}
rowId={item.rowId}
span={span}
isSelected={isSelected}
onSelectId={onSelectAudio}
variant="audio"
waveformPeaks={peaks}
waveformSegmentSpan={normalizedWaveformSpan}
waveformGain={Math.max(0, Math.min(2, item.audioGain ?? 1))}
waveformNormalize={Boolean(item.audioNormalize)}
>
{item.label}
</Item>
);
@@ -33,6 +33,8 @@ export interface TimelineRenderItem {
span: Span;
label: string;
audioPath?: string;
audioGain?: number;
audioNormalize?: boolean;
zoomDepth?: number;
zoomMode?: ZoomMode;
speedValue?: number;
@@ -70,6 +70,8 @@ export function buildTimelineItems(params: {
span: { start: region.startMs, end: region.endMs },
label: getAudioLabel(region),
audioPath: region.audioPath,
audioGain: region.volume,
audioNormalize: Boolean(region.normalize),
variant: "audio",
}));
+1
View File
@@ -480,6 +480,7 @@ export interface AudioRegion {
endMs: number;
audioPath: string;
volume: number;
normalize?: boolean;
trackIndex?: number;
}
+30 -12
View File
@@ -6,13 +6,14 @@ import type {
SourceAudioTrackSettings,
TrimRegion,
} from "@/components/video-editor/types";
import {
buildResolvedAudioPlan,
getSourceTrackIdFromPath,
} from "@/lib/exporter/audioRoutingEngine";
import { estimateCompanionAudioStartDelaySeconds } from "@/lib/mediaTiming";
import { resolveMediaElementSource } from "./localMediaSource";
import type { VideoMuxer } from "./muxer";
import {
getSourceTrackIdFromPath,
resolveSourceTrackRoutingPolicy,
} from "./sourceTrackRoutingPolicy";
import { resolveSourceTrackRoutingPolicy } from "./sourceTrackRoutingPolicy";
const AUDIO_BITRATE = 128_000;
const DECODE_BACKPRESSURE_LIMIT = 20;
@@ -22,6 +23,7 @@ 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 USER_AUDIO_NORMALIZE_GAIN = 1.35;
interface TimelineSlice {
sourceStartMs: number;
@@ -600,13 +602,28 @@ export class AudioProcessor {
if (this.cancelled) throw new Error("Export cancelled");
this.onProgress?.(0);
const routingPolicy = resolveSourceTrackRoutingPolicy(
videoUrl,
const resolvedPlan = buildResolvedAudioPlan({
videoResource: videoUrl,
sourceAudioFallbackPaths,
);
audioRegions,
sourceTrackGainById: {
mic: Math.max(0, Math.min(2, sourceAudioTrackSettings?.mic?.volume ?? 1)),
system: Math.max(0, Math.min(2, sourceAudioTrackSettings?.system?.volume ?? 1)),
mixed: Math.max(0, Math.min(2, sourceAudioTrackSettings?.mixed?.volume ?? 1)),
},
embeddedGain: Math.max(
0,
Math.min(
2,
sourceAudioTrackSettings?.mixed?.volume ??
sourceAudioTrackSettings?.system?.volume ??
1,
),
),
});
// Decode embedded source audio separately from companion sidecars.
const mainBuffer = routingPolicy.includeEmbeddedInExport
const mainBuffer = resolvedPlan.includeEmbeddedInExport
? await this.decodeAudioFromUrl(videoUrl)
: null;
const mainBufferGainSettings =
@@ -622,8 +639,8 @@ export class AudioProcessor {
[];
const refDuration =
mainBuffer?.duration ??
(routingPolicy.playbackPaths.length > 0 ? await this.getMediaDurationSec(videoUrl) : 0);
for (const audioPath of routingPolicy.playbackPaths) {
(resolvedPlan.playbackPaths.length > 0 ? await this.getMediaDurationSec(videoUrl) : 0);
for (const audioPath of resolvedPlan.playbackPaths) {
if (this.cancelled) throw new Error("Export cancelled");
const buffer = await this.decodeAudioFromUrl(audioPath);
if (!buffer) continue;
@@ -662,7 +679,7 @@ export class AudioProcessor {
let sourceDurationSec: number;
if (mainBufferEntry?.buffer) {
sourceDurationSec = mainBufferEntry.buffer.duration;
} else if (routingPolicy.playbackPaths.length > 0 || regionEntries.length > 0) {
} else if (resolvedPlan.playbackPaths.length > 0 || regionEntries.length > 0) {
sourceDurationSec = await this.getMediaDurationSec(videoUrl);
} else {
sourceDurationSec = primaryBuffer?.duration ?? 0;
@@ -892,7 +909,8 @@ export class AudioProcessor {
if (duration <= 0.001) return;
const gainNode = ctx.createGain();
gainNode.gain.value = Math.max(0, Math.min(1, region.volume));
const normalizeGain = region.normalize ? USER_AUDIO_NORMALIZE_GAIN : 1;
gainNode.gain.value = Math.max(0, Math.min(1, region.volume * normalizeGain));
gainNode.connect(ctx.destination);
const source = ctx.createBufferSource();
+129
View File
@@ -0,0 +1,129 @@
import type { AudioRegion } from "@/components/video-editor/types";
import { resolveSourceAudioFallbackPaths } from "./sourceAudioFallback";
export type SourceTrackId = "mic" | "system" | "mixed";
export type ResolvedAudioTrackKind = "user" | "system" | "mic" | "mixed" | "embedded";
const USER_AUDIO_NORMALIZE_GAIN = 1.35;
export interface ResolvedAudioTrack {
id: string;
kind: ResolvedAudioTrackKind;
sourceRef: {
path: string;
startDelayMs: number;
};
gain: number;
timelineBinding: {
startMs: number;
endMs: number;
};
}
export interface ResolvedAudioPlan {
hasEmbeddedSourceAudio: boolean;
pathsByTrack: Partial<Record<SourceTrackId, string>>;
playbackPaths: string[];
muteEmbeddedPreview: boolean;
includeEmbeddedInExport: boolean;
tracks: ResolvedAudioTrack[];
masterGain: number;
}
export function getSourceTrackIdFromPath(audioPath: string): SourceTrackId {
const normalized = audioPath.toLowerCase();
if (normalized.includes(".mic.")) return "mic";
if (normalized.includes(".system.")) return "system";
return "mixed";
}
function clampGain(value: number, max: number) {
if (!Number.isFinite(value)) return 1;
return Math.max(0, Math.min(max, value));
}
export function buildResolvedAudioPlan(input: {
videoResource: string | null | undefined;
sourceAudioFallbackPaths: string[] | null | undefined;
audioRegions?: AudioRegion[];
sourceTrackGainById?: Partial<Record<SourceTrackId, number>>;
embeddedGain?: number;
masterGain?: number;
}): ResolvedAudioPlan {
const { hasEmbeddedSourceAudio, externalAudioPaths } = resolveSourceAudioFallbackPaths(
input.videoResource,
input.sourceAudioFallbackPaths,
);
const pathsByTrack: Partial<Record<SourceTrackId, string>> = {};
for (const path of externalAudioPaths) {
const trackId = getSourceTrackIdFromPath(path);
if (!pathsByTrack[trackId]) {
pathsByTrack[trackId] = path;
}
}
const hasDedicatedTracks = Boolean(pathsByTrack.system || pathsByTrack.mic);
const playbackPaths: string[] = [];
if (pathsByTrack.system) playbackPaths.push(pathsByTrack.system);
if (pathsByTrack.mic) playbackPaths.push(pathsByTrack.mic);
if (!hasDedicatedTracks && pathsByTrack.mixed) playbackPaths.push(pathsByTrack.mixed);
const includeEmbeddedInExport = !pathsByTrack.system && !pathsByTrack.mixed;
const resolvedRegions = (input.audioRegions ?? []).slice().sort((a, b) => a.startMs - b.startMs);
const tracks: ResolvedAudioTrack[] = resolvedRegions.map((region) => ({
id: `user:${region.id}`,
kind: "user",
sourceRef: {
path: region.audioPath,
startDelayMs: 0,
},
gain: clampGain(region.volume * (region.normalize ? USER_AUDIO_NORMALIZE_GAIN : 1), 1),
timelineBinding: {
startMs: Math.max(0, region.startMs),
endMs: Math.max(0, region.endMs),
},
}));
for (const audioPath of playbackPaths) {
const trackId = getSourceTrackIdFromPath(audioPath);
tracks.push({
id: `${trackId}:${audioPath}`,
kind: trackId,
sourceRef: {
path: audioPath,
startDelayMs: 0,
},
gain: clampGain(input.sourceTrackGainById?.[trackId] ?? 1, 2),
timelineBinding: {
startMs: 0,
endMs: Number.POSITIVE_INFINITY,
},
});
}
if (hasEmbeddedSourceAudio && input.videoResource) {
tracks.push({
id: `embedded:${input.videoResource}`,
kind: "embedded",
sourceRef: {
path: input.videoResource,
startDelayMs: 0,
},
gain: clampGain(input.embeddedGain ?? input.sourceTrackGainById?.mixed ?? 1, 2),
timelineBinding: {
startMs: 0,
endMs: Number.POSITIVE_INFINITY,
},
});
}
return {
hasEmbeddedSourceAudio,
pathsByTrack,
playbackPaths,
muteEmbeddedPreview: hasDedicatedTracks,
includeEmbeddedInExport,
tracks,
masterGain: clampGain(input.masterGain ?? 1, 1),
};
}
+13 -30
View File
@@ -1,13 +1,10 @@
import { resolveSourceAudioFallbackPaths } from "./sourceAudioFallback";
import {
buildResolvedAudioPlan,
getSourceTrackIdFromPath,
type SourceTrackId,
} from "./audioRoutingEngine";
export type SourceTrackId = "mic" | "system" | "mixed";
export function getSourceTrackIdFromPath(audioPath: string): SourceTrackId {
const normalized = audioPath.toLowerCase();
if (normalized.includes(".mic.")) return "mic";
if (normalized.includes(".system.")) return "system";
return "mixed";
}
export { getSourceTrackIdFromPath, type SourceTrackId };
export interface SourceTrackRoutingPolicy {
hasEmbeddedSourceAudio: boolean;
@@ -21,30 +18,16 @@ export function resolveSourceTrackRoutingPolicy(
videoResource: string | null | undefined,
sourceAudioFallbackPaths: string[] | null | undefined,
): SourceTrackRoutingPolicy {
const { hasEmbeddedSourceAudio, externalAudioPaths } = resolveSourceAudioFallbackPaths(
const plan = buildResolvedAudioPlan({
videoResource,
sourceAudioFallbackPaths,
);
const pathsByTrack: Partial<Record<SourceTrackId, string>> = {};
for (const path of externalAudioPaths) {
const trackId = getSourceTrackIdFromPath(path);
if (!pathsByTrack[trackId]) {
pathsByTrack[trackId] = path;
}
}
const hasDedicatedTracks = Boolean(pathsByTrack.system || pathsByTrack.mic);
const playbackPaths: string[] = [];
if (pathsByTrack.system) playbackPaths.push(pathsByTrack.system);
if (pathsByTrack.mic) playbackPaths.push(pathsByTrack.mic);
if (!hasDedicatedTracks && pathsByTrack.mixed) playbackPaths.push(pathsByTrack.mixed);
});
return {
hasEmbeddedSourceAudio,
pathsByTrack,
playbackPaths,
muteEmbeddedPreview: hasDedicatedTracks,
includeEmbeddedInExport: !pathsByTrack.system && !pathsByTrack.mixed,
hasEmbeddedSourceAudio: plan.hasEmbeddedSourceAudio,
pathsByTrack: plan.pathsByTrack,
playbackPaths: plan.playbackPaths,
muteEmbeddedPreview: plan.muteEmbeddedPreview,
includeEmbeddedInExport: plan.includeEmbeddedInExport,
};
}