refactor: make export audio consistent to preview

fixes audio export issue with .mkv files
This commit is contained in:
Maze Winther
2026-02-23 09:28:54 +01:00
parent 1762dc077d
commit 86a3592c88
2 changed files with 63 additions and 9 deletions
+62 -6
View File
@@ -8,19 +8,23 @@ import type { MediaAsset } from "@/types/assets";
import { canElementHaveAudio } from "@/lib/timeline/element-utils"; import { canElementHaveAudio } from "@/lib/timeline/element-utils";
import { canTracktHaveAudio } from "@/lib/timeline"; import { canTracktHaveAudio } from "@/lib/timeline";
import { mediaSupportsAudio } from "@/lib/media/media-utils"; import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
const MAX_AUDIO_CHANNELS = 2;
const EXPORT_SAMPLE_RATE = 44100;
export type CollectedAudioElement = Omit< export type CollectedAudioElement = Omit<
AudioElement, AudioElement,
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl" "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
> & { buffer: AudioBuffer }; > & { buffer: AudioBuffer };
export function createAudioContext(): AudioContext { export function createAudioContext({ sampleRate }: { sampleRate?: number } = {}): AudioContext {
const AudioContextConstructor = const AudioContextConstructor =
window.AudioContext || window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }) (window as typeof window & { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext; .webkitAudioContext;
return new AudioContextConstructor(); return new AudioContextConstructor(sampleRate ? { sampleRate } : undefined);
} }
export interface DecodedAudio { export interface DecodedAudio {
@@ -170,12 +174,64 @@ async function resolveAudioBufferForVideoElement({
mediaAsset: MediaAsset; mediaAsset: MediaAsset;
audioContext: AudioContext; audioContext: AudioContext;
}): Promise<AudioBuffer | null> { }): Promise<AudioBuffer | null> {
const input = new Input({
source: new BlobSource(mediaAsset.file),
formats: ALL_FORMATS,
});
try { try {
const arrayBuffer = await mediaAsset.file.arrayBuffer(); const audioTrack = await input.getPrimaryAudioTrack();
return await audioContext.decodeAudioData(arrayBuffer.slice(0)); if (!audioTrack) return null;
const sink = new AudioBufferSink(audioTrack);
const targetSampleRate = audioContext.sampleRate;
const chunks: AudioBuffer[] = [];
let totalSamples = 0;
for await (const { buffer } of sink.buffers(0)) {
chunks.push(buffer);
totalSamples += buffer.length;
}
if (chunks.length === 0) return null;
const nativeSampleRate = chunks[0].sampleRate;
const numChannels = Math.min(MAX_AUDIO_CHANNELS, chunks[0].numberOfChannels);
const nativeChannels = Array.from(
{ length: numChannels },
() => new Float32Array(totalSamples),
);
let offset = 0;
for (const chunk of chunks) {
for (let channel = 0; channel < numChannels; channel++) {
const sourceData = chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1));
nativeChannels[channel].set(sourceData, offset);
}
offset += chunk.length;
}
// use OfflineAudioContext for high-quality resampling to target rate
const outputSamples = Math.ceil(totalSamples * (targetSampleRate / nativeSampleRate));
const offlineContext = new OfflineAudioContext(numChannels, outputSamples, targetSampleRate);
const nativeBuffer = audioContext.createBuffer(numChannels, totalSamples, nativeSampleRate);
for (let ch = 0; ch < numChannels; ch++) {
nativeBuffer.copyToChannel(nativeChannels[ch], ch);
}
const sourceNode = offlineContext.createBufferSource();
sourceNode.buffer = nativeBuffer;
sourceNode.connect(offlineContext.destination);
sourceNode.start(0);
return await offlineContext.startRendering();
} catch (error) { } catch (error) {
console.warn("Failed to decode video audio:", error); console.warn("Failed to decode video audio:", error);
return null; return null;
} finally {
input.dispose();
} }
} }
@@ -422,7 +478,7 @@ export async function createTimelineAudioBuffer({
tracks, tracks,
mediaAssets, mediaAssets,
duration, duration,
sampleRate = 44100, sampleRate = EXPORT_SAMPLE_RATE,
audioContext, audioContext,
}: { }: {
tracks: TimelineTrack[]; tracks: TimelineTrack[];
@@ -431,7 +487,7 @@ export async function createTimelineAudioBuffer({
sampleRate?: number; sampleRate?: number;
audioContext?: AudioContext; audioContext?: AudioContext;
}): Promise<AudioBuffer | null> { }): Promise<AudioBuffer | null> {
const context = audioContext ?? createAudioContext(); const context = audioContext ?? createAudioContext({ sampleRate });
const audioElements = await collectAudioElements({ const audioElements = await collectAudioElements({
tracks, tracks,
@@ -13,11 +13,9 @@ import {
QUALITY_VERY_HIGH, QUALITY_VERY_HIGH,
} from "mediabunny"; } from "mediabunny";
import type { RootNode } from "./nodes/root-node"; import type { RootNode } from "./nodes/root-node";
import type { ExportFormat, ExportQuality } from "@/types/export";
import { CanvasRenderer } from "./canvas-renderer"; import { CanvasRenderer } from "./canvas-renderer";
export type ExportFormat = "mp4" | "webm";
export type ExportQuality = "low" | "medium" | "high" | "very_high";
type ExportParams = { type ExportParams = {
width: number; width: number;
height: number; height: number;