You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

288 lines
9.3 KiB

'use client';
import { useCallback } from 'react';
import {
Input,
AudioBufferSink,
BlobSource,
ALL_FORMATS,
} from 'mediabunny';
const MINIMUM_AUDIO_DURATION = 0.1;
interface AudioProcessingOptions {
offset?: number;
fadeIn?: number;
fadeOut?: number;
}
interface AudioMixProgress {
message: string;
progress: number;
}
interface AudioData {
buffer: AudioBuffer;
duration: number;
}
interface UseAudioMixingReturn {
prepareAudio: (
audioBlob: Blob,
videoDuration: number,
onProgress?: (progress: AudioMixProgress) => void,
options?: AudioProcessingOptions
) => Promise<AudioData | null>;
}
/**
* Decode audio blob using MediaBunny. Supports MP3, WAV, OGG and container formats.
* Returns an array of decoded AudioBuffers.
*/
async function decodeWithMediaBunny(
audioBlob: Blob,
onProgress?: (progress: AudioMixProgress) => void,
): Promise<AudioBuffer[]> {
onProgress?.({ message: 'Reading audio tracks...', progress: 20 });
let blobSource: BlobSource | undefined;
let input: Input | undefined;
let sink: AudioBufferSink | undefined;
try {
blobSource = new BlobSource(audioBlob);
input = new Input({ source: blobSource, formats: ALL_FORMATS });
const audioTrack = await input.getPrimaryAudioTrack();
if (!audioTrack) {
throw new Error('No audio tracks found in file');
}
const decodable = await audioTrack.canDecode();
if (!decodable) {
throw new Error('Audio codec not supported by this browser');
}
sink = new AudioBufferSink(audioTrack);
const audioDuration = await input.computeDuration();
onProgress?.({ message: 'Decoding audio...', progress: 30 });
const decodedBuffers: AudioBuffer[] = [];
for await (const wrappedBuffer of sink.buffers(0, audioDuration)) {
if (wrappedBuffer?.buffer) {
decodedBuffers.push(wrappedBuffer.buffer);
}
}
if (decodedBuffers.length === 0) {
throw new Error('Failed to decode audio');
}
return decodedBuffers;
} finally {
// Defensively close resources — types may not declare close() but implementations may have it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const closeable = (obj: unknown): obj is { close(): Promise<void> } =>
obj != null && typeof (obj as any).close === 'function';
if (closeable(sink)) {
try { await sink.close(); } catch (e) { console.warn('Failed to close sink:', e); }
}
if (closeable(input)) {
try { await input.close(); } catch (e) { console.warn('Failed to close input:', e); }
}
if (closeable(blobSource)) {
try { await blobSource.close(); } catch (e) { console.warn('Failed to close blobSource:', e); }
}
}
}
/**
* Decode audio blob using the Web Audio API as a fallback.
*/
async function decodeWithWebAudio(
audioBlob: Blob,
onProgress?: (progress: AudioMixProgress) => void,
): Promise<AudioBuffer[]> {
onProgress?.({ message: 'Decoding audio (Web Audio API)...', progress: 30 });
const arrayBuffer = await audioBlob.arrayBuffer();
const audioContext = new AudioContext();
try {
const decoded = await audioContext.decodeAudioData(arrayBuffer);
return [decoded];
} finally {
await audioContext.close();
}
}
/**
* Standalone async function to prepare audio for mixing with video.
* Uses MediaBunny for decoding, with Web Audio API as fallback.
*/
export async function prepareAudioAsync(
audioBlob: Blob,
videoDuration: number,
onProgress?: (progress: AudioMixProgress) => void,
options?: AudioProcessingOptions
): Promise<AudioData | null> {
try {
onProgress?.({ message: 'Loading audio file...', progress: 10 });
let decodedBuffers: AudioBuffer[];
try {
decodedBuffers = await decodeWithMediaBunny(audioBlob, onProgress);
} catch (mediaBunnyError) {
console.warn('MediaBunny audio decode failed, falling back to Web Audio API:', mediaBunnyError);
decodedBuffers = await decodeWithWebAudio(audioBlob, onProgress);
}
const sampleRate = decodedBuffers[0].sampleRate;
const channels = decodedBuffers[0].numberOfChannels;
// Compute the total decoded audio duration from buffers
const decodedDuration = decodedBuffers.reduce((sum, buf) => sum + buf.length, 0) / sampleRate;
// If videoDuration is 0 (unknown), use the full audio duration
const targetDuration = videoDuration > 0
? Math.max(MINIMUM_AUDIO_DURATION, videoDuration)
: Math.max(MINIMUM_AUDIO_DURATION, decodedDuration);
const totalSamples = Math.max(1, Math.floor(targetDuration * sampleRate));
onProgress?.({ message: 'Processing audio...', progress: 60 });
const mergedBuffer = new AudioBuffer({
length: totalSamples,
numberOfChannels: channels,
sampleRate,
});
const offsetSeconds = options?.offset ?? 0;
const offsetSamples = Math.floor(offsetSeconds * sampleRate);
let writeOffset = 0;
let sourceSkipSamples = 0;
if (offsetSamples > 0) {
writeOffset = Math.min(offsetSamples, totalSamples);
} else if (offsetSamples < 0) {
sourceSkipSamples = Math.abs(offsetSamples);
}
// Skip offset samples across decoded buffers
let samplesToSkip = sourceSkipSamples;
let bufferIndex = 0;
let bufferOffset = 0;
while (samplesToSkip > 0 && bufferIndex < decodedBuffers.length) {
const buffer = decodedBuffers[bufferIndex];
const availableInBuffer = buffer.length - bufferOffset;
if (samplesToSkip >= availableInBuffer) {
samplesToSkip -= availableInBuffer;
bufferIndex++;
bufferOffset = 0;
} else {
bufferOffset = samplesToSkip;
samplesToSkip = 0;
}
}
// Copy decoded audio into the merged buffer
while (writeOffset < totalSamples && bufferIndex < decodedBuffers.length) {
const buffer = decodedBuffers[bufferIndex];
const remainingSamples = totalSamples - writeOffset;
const availableInBuffer = buffer.length - bufferOffset;
const writeLength = Math.min(availableInBuffer, remainingSamples);
for (let channel = 0; channel < channels; channel++) {
const channelData = buffer.getChannelData(channel).subarray(bufferOffset, bufferOffset + writeLength);
mergedBuffer.getChannelData(channel).set(channelData, writeOffset);
}
writeOffset += writeLength;
bufferOffset += writeLength;
if (bufferOffset >= buffer.length) {
bufferIndex++;
bufferOffset = 0;
}
}
// Loop audio to fill remaining duration if needed
while (writeOffset < totalSamples && decodedBuffers.length > 0) {
for (const buffer of decodedBuffers) {
const remainingSamples = totalSamples - writeOffset;
if (remainingSamples <= 0) break;
const writeLength = Math.min(buffer.length, remainingSamples);
for (let channel = 0; channel < channels; channel++) {
const channelData = buffer.getChannelData(channel).subarray(0, writeLength);
mergedBuffer.getChannelData(channel).set(channelData, writeOffset);
}
writeOffset += writeLength;
}
}
applyFades(mergedBuffer, options);
onProgress?.({ message: 'Audio ready for mixing', progress: 95 });
return {
buffer: mergedBuffer,
duration: totalSamples / sampleRate,
};
} catch (error) {
console.error('[VideoStitch] Audio preparation failed:', error instanceof Error ? error.message : error, error);
throw new Error(`Failed to process audio: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* React hook wrapper for prepareAudioAsync
*/
export const useAudioMixing = (): UseAudioMixingReturn => {
const prepareAudio = useCallback(
async (
audioBlob: Blob,
videoDuration: number,
onProgress?: (progress: AudioMixProgress) => void,
options?: AudioProcessingOptions
): Promise<AudioData | null> => {
return prepareAudioAsync(audioBlob, videoDuration, onProgress, options);
},
[]
);
return { prepareAudio };
};
export function applyFades(buffer: AudioBuffer, options?: { fadeIn?: number; fadeOut?: number }) {
if (!options) return;
const fadeInSeconds = Math.max(0, options.fadeIn ?? 0);
const fadeOutSeconds = Math.max(0, options.fadeOut ?? 0);
if (fadeInSeconds === 0 && fadeOutSeconds === 0) return;
const totalSamples = buffer.length;
if (totalSamples === 0) return;
let fadeInSamples = Math.min(totalSamples, Math.floor(fadeInSeconds * buffer.sampleRate));
let fadeOutSamples = Math.min(totalSamples, Math.floor(fadeOutSeconds * buffer.sampleRate));
if (fadeInSamples + fadeOutSamples > totalSamples) {
const scale = totalSamples / Math.max(1, fadeInSamples + fadeOutSamples);
fadeInSamples = Math.floor(fadeInSamples * scale);
fadeOutSamples = Math.floor(fadeOutSamples * scale);
}
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
const channelData = buffer.getChannelData(channel);
if (fadeInSamples > 0) {
for (let i = 0; i < fadeInSamples; i++) {
channelData[i] *= i / fadeInSamples;
}
}
if (fadeOutSamples > 0) {
for (let i = 0; i < fadeOutSamples; i++) {
const sampleIndex = totalSamples - fadeOutSamples + i;
if (sampleIndex < 0 || sampleIndex >= totalSamples) continue;
channelData[sampleIndex] *= (fadeOutSamples - i) / fadeOutSamples;
}
}
}
}