mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 16:25:35 +00:00
fix all audios issues it seems finally. with WebAudio API instead of html audio.
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
|
||||
@@ -480,6 +480,7 @@ export interface AudioRegion {
|
||||
endMs: number;
|
||||
audioPath: string;
|
||||
volume: number;
|
||||
normalize?: boolean;
|
||||
trackIndex?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user