Correct files to integrate with the site for a good audio to a/v osciloscope converter

Now has to be only implemented in the actual website
This commit is contained in:
2025-12-21 13:19:06 +01:00
parent e227743728
commit ad6587978a
14 changed files with 4124 additions and 1384 deletions
Regular → Executable
+50 -227
View File
@@ -1,246 +1,69 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useRef, useState, useCallback } from 'react';
interface AudioAnalyzerState {
isActive: boolean;
error: string | null;
source: 'microphone' | 'file' | null;
fileName: string | null;
isPlaying: boolean;
export interface AudioData {
leftChannel: Float32Array;
rightChannel: Float32Array;
sampleRate: number;
duration: number;
}
export const useAudioAnalyzer = () => {
const [state, setState] = useState<AudioAnalyzerState>({
isActive: false,
error: null,
source: null,
fileName: null,
isPlaying: false,
});
export function useAudioAnalyzer() {
const [audioData, setAudioData] = useState<AudioData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const analyzerLeftRef = useRef<AnalyserNode | null>(null);
const analyzerRightRef = useRef<AnalyserNode | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | MediaElementAudioSourceNode | null>(null);
const splitterRef = useRef<ChannelSplitterNode | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const analysisGainNodeRef = useRef<GainNode | null>(null);
const audioElementRef = useRef<HTMLAudioElement | null>(null);
const gainValueRef = useRef<number>(3); // Default higher gain for analysis sensitivity only
const getTimeDomainData = useCallback(() => {
if (!analyzerLeftRef.current) return null;
const bufferLength = analyzerLeftRef.current.fftSize;
const dataArray = new Uint8Array(bufferLength);
analyzerLeftRef.current.getByteTimeDomainData(dataArray);
return dataArray;
}, []);
const getStereoData = useCallback(() => {
if (!analyzerLeftRef.current || !analyzerRightRef.current) return null;
const bufferLength = analyzerLeftRef.current.fftSize;
const leftData = new Uint8Array(bufferLength);
const rightData = new Uint8Array(bufferLength);
analyzerLeftRef.current.getByteTimeDomainData(leftData);
analyzerRightRef.current.getByteTimeDomainData(rightData);
return { left: leftData, right: rightData };
}, []);
const setGain = useCallback((value: number) => {
gainValueRef.current = value;
if (analysisGainNodeRef.current) {
analysisGainNodeRef.current.gain.value = value;
}
}, []);
const setupAnalyzers = useCallback((audioContext: AudioContext) => {
// Create gain node for analysis sensitivity (does NOT affect audio output)
analysisGainNodeRef.current = audioContext.createGain();
analysisGainNodeRef.current.gain.value = gainValueRef.current;
// Create channel splitter for stereo
splitterRef.current = audioContext.createChannelSplitter(2);
// Create analyzers for each channel
analyzerLeftRef.current = audioContext.createAnalyser();
analyzerRightRef.current = audioContext.createAnalyser();
// Configure analyzers for higher sensitivity
const fftSize = 2048;
analyzerLeftRef.current.fftSize = fftSize;
analyzerRightRef.current.fftSize = fftSize;
analyzerLeftRef.current.smoothingTimeConstant = 0.5;
analyzerRightRef.current.smoothingTimeConstant = 0.5;
analyzerLeftRef.current.minDecibels = -90;
analyzerRightRef.current.minDecibels = -90;
analyzerLeftRef.current.maxDecibels = -10;
analyzerRightRef.current.maxDecibels = -10;
}, []);
const startMicrophone = useCallback(async () => {
try {
setState(prev => ({ ...prev, isActive: false, error: null }));
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});
streamRef.current = stream;
audioContextRef.current = new AudioContext();
setupAnalyzers(audioContextRef.current);
// Create source from microphone
const micSource = audioContextRef.current.createMediaStreamSource(stream);
sourceRef.current = micSource;
// Connect: source -> analysisGain -> splitter -> analyzers
// (microphone doesn't need output, just analysis)
micSource.connect(analysisGainNodeRef.current!);
analysisGainNodeRef.current!.connect(splitterRef.current!);
splitterRef.current!.connect(analyzerLeftRef.current!, 0);
splitterRef.current!.connect(analyzerRightRef.current!, 1);
setState({
isActive: true,
error: null,
source: 'microphone',
fileName: null,
isPlaying: true
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to access microphone';
setState(prev => ({ ...prev, isActive: false, error: message }));
}
}, [setupAnalyzers]);
const loadAudioFile = useCallback(async (file: File) => {
setIsLoading(true);
setError(null);
setFileName(file.name);
try {
// Stop any existing audio
stop();
// Create or reuse AudioContext
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext();
}
const audioContext = audioContextRef.current;
// Read file as ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
setState(prev => ({ ...prev, isActive: false, error: null }));
// Decode audio data
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// Extract channel data
const leftChannel = audioBuffer.getChannelData(0);
const rightChannel = audioBuffer.numberOfChannels > 1
? audioBuffer.getChannelData(1)
: audioBuffer.getChannelData(0); // Mono: duplicate left channel
// Create audio element
const audioElement = new Audio();
audioElement.src = URL.createObjectURL(file);
audioElement.loop = true;
audioElementRef.current = audioElement;
audioContextRef.current = new AudioContext();
setupAnalyzers(audioContextRef.current);
// Create source from audio element
const audioSource = audioContextRef.current.createMediaElementSource(audioElement);
sourceRef.current = audioSource;
// For files: source -> destination (clean audio output)
// source -> analysisGain -> splitter -> analyzers (boosted for visualization)
audioSource.connect(audioContextRef.current.destination);
audioSource.connect(analysisGainNodeRef.current!);
analysisGainNodeRef.current!.connect(splitterRef.current!);
splitterRef.current!.connect(analyzerLeftRef.current!, 0);
splitterRef.current!.connect(analyzerRightRef.current!, 1);
// Start playing
await audioElement.play();
setState({
isActive: true,
error: null,
source: 'file',
fileName: file.name,
isPlaying: true
setAudioData({
leftChannel: new Float32Array(leftChannel),
rightChannel: new Float32Array(rightChannel),
sampleRate: audioBuffer.sampleRate,
duration: audioBuffer.duration,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load audio file';
setState(prev => ({ ...prev, isActive: false, error: message }));
}
}, [setupAnalyzers]);
const togglePlayPause = useCallback(() => {
if (!audioElementRef.current) return;
if (audioElementRef.current.paused) {
audioElementRef.current.play();
setState(prev => ({ ...prev, isPlaying: true }));
} else {
audioElementRef.current.pause();
setState(prev => ({ ...prev, isPlaying: false }));
setError(err instanceof Error ? err.message : 'Failed to load audio file');
setAudioData(null);
} finally {
setIsLoading(false);
}
}, []);
const stop = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (audioElementRef.current) {
audioElementRef.current.pause();
audioElementRef.current.src = '';
audioElementRef.current = null;
}
if (sourceRef.current) {
sourceRef.current.disconnect();
sourceRef.current = null;
}
if (analysisGainNodeRef.current) {
analysisGainNodeRef.current.disconnect();
analysisGainNodeRef.current = null;
}
if (splitterRef.current) {
splitterRef.current.disconnect();
splitterRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
analyzerLeftRef.current = null;
analyzerRightRef.current = null;
setState({
isActive: false,
error: null,
source: null,
fileName: null,
isPlaying: false
});
}, []);
useEffect(() => {
return () => {
stop();
};
}, [stop]);
const getAudioElement = useCallback(() => {
return audioElementRef.current;
const reset = useCallback(() => {
setAudioData(null);
setFileName(null);
setError(null);
}, []);
return {
...state,
startMicrophone,
audioData,
isLoading,
error,
fileName,
loadAudioFile,
togglePlayPause,
stop,
setGain,
getTimeDomainData,
getStereoData,
getAudioElement,
reset,
};
};
}
+274 -443
View File
@@ -1,337 +1,16 @@
import { useState, useCallback, useRef } from 'react';
export type ExportStage = 'idle' | 'preparing' | 'rendering' | 'encoding' | 'complete';
interface ExportState {
isExporting: boolean;
progress: number;
error: string | null;
stage: ExportStage;
fps: number;
}
interface ExportOptions {
fps: number;
format: 'webm' | 'mp4';
width: number;
height: number;
}
interface WavHeader {
sampleRate: number;
numChannels: number;
bitsPerSample: number;
dataOffset: number;
dataSize: number;
}
// Parse WAV header without loading entire file
async function parseWavHeader(file: File): Promise<WavHeader> {
const headerBuffer = await file.slice(0, 44).arrayBuffer();
const view = new DataView(headerBuffer);
// Verify RIFF header
const riff = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
if (riff !== 'RIFF') throw new Error('Not a valid WAV file');
const wave = String.fromCharCode(view.getUint8(8), view.getUint8(9), view.getUint8(10), view.getUint8(11));
if (wave !== 'WAVE') throw new Error('Not a valid WAV file');
// Find fmt chunk
const numChannels = view.getUint16(22, true);
const sampleRate = view.getUint32(24, true);
const bitsPerSample = view.getUint16(34, true);
// Find data chunk - scan for 'data' marker
let dataOffset = 36;
let dataSize = 0;
// Read more bytes to find data chunk
const extendedBuffer = await file.slice(0, Math.min(1024, file.size)).arrayBuffer();
const extendedView = new DataView(extendedBuffer);
for (let i = 36; i < extendedBuffer.byteLength - 8; i++) {
const marker = String.fromCharCode(
extendedView.getUint8(i),
extendedView.getUint8(i + 1),
extendedView.getUint8(i + 2),
extendedView.getUint8(i + 3)
);
if (marker === 'data') {
dataOffset = i + 8;
dataSize = extendedView.getUint32(i + 4, true);
break;
}
}
if (dataSize === 0) {
// Estimate from file size
dataSize = file.size - dataOffset;
}
return { sampleRate, numChannels, bitsPerSample, dataOffset, dataSize };
}
// Read a chunk of samples from WAV file
async function readWavChunk(
file: File,
header: WavHeader,
startSample: number,
numSamples: number
): Promise<{ left: Float32Array; right: Float32Array }> {
const bytesPerSample = header.bitsPerSample / 8;
const bytesPerFrame = bytesPerSample * header.numChannels;
const startByte = header.dataOffset + (startSample * bytesPerFrame);
const endByte = Math.min(startByte + (numSamples * bytesPerFrame), file.size);
const chunk = await file.slice(startByte, endByte).arrayBuffer();
const view = new DataView(chunk);
const actualSamples = Math.floor(chunk.byteLength / bytesPerFrame);
const left = new Float32Array(actualSamples);
const right = new Float32Array(actualSamples);
for (let i = 0; i < actualSamples; i++) {
const offset = i * bytesPerFrame;
if (header.bitsPerSample === 16) {
left[i] = view.getInt16(offset, true) / 32768;
right[i] = header.numChannels > 1
? view.getInt16(offset + 2, true) / 32768
: left[i];
} else if (header.bitsPerSample === 24) {
const l = (view.getUint8(offset) | (view.getUint8(offset + 1) << 8) | (view.getInt8(offset + 2) << 16));
left[i] = l / 8388608;
if (header.numChannels > 1) {
const r = (view.getUint8(offset + 3) | (view.getUint8(offset + 4) << 8) | (view.getInt8(offset + 5) << 16));
right[i] = r / 8388608;
} else {
right[i] = left[i];
}
} else if (header.bitsPerSample === 32) {
left[i] = view.getFloat32(offset, true);
right[i] = header.numChannels > 1
? view.getFloat32(offset + 4, true)
: left[i];
} else {
// 8-bit
left[i] = (view.getUint8(offset) - 128) / 128;
right[i] = header.numChannels > 1
? (view.getUint8(offset + 1) - 128) / 128
: left[i];
}
}
return { left, right };
}
export const useOfflineVideoExport = () => {
const [state, setState] = useState<ExportState>({
const [state, setState] = useState({
isExporting: false,
progress: 0,
error: null,
stage: 'idle',
stage: 'idle' as 'idle' | 'preparing' | 'rendering' | 'encoding' | 'complete',
fps: 0,
});
const cancelledRef = useRef(false);
const generateVideoWithAudio = useCallback(async (
audioFile: File,
drawFrame: (ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => void,
options: ExportOptions
): Promise<Blob | null> => {
cancelledRef.current = false;
setState({ isExporting: true, progress: 0, error: null, stage: 'preparing', fps: 0 });
try {
const { fps, width, height } = options;
const isWav = audioFile.name.toLowerCase().endsWith('.wav');
console.log(`Starting memory-efficient export: ${audioFile.name} (${(audioFile.size / 1024 / 1024).toFixed(2)} MB)`);
let sampleRate: number;
let totalSamples: number;
let getChunk: (startSample: number, numSamples: number) => Promise<{ left: Float32Array; right: Float32Array }>;
if (isWav) {
// Memory-efficient WAV streaming
console.log('Using streaming WAV parser (memory efficient)');
const header = await parseWavHeader(audioFile);
sampleRate = header.sampleRate;
const bytesPerSample = header.bitsPerSample / 8 * header.numChannels;
totalSamples = Math.floor(header.dataSize / bytesPerSample);
getChunk = (startSample, numSamples) => readWavChunk(audioFile, header, startSample, numSamples);
console.log(`WAV: ${header.numChannels}ch, ${sampleRate}Hz, ${header.bitsPerSample}bit, ${totalSamples} samples`);
} else {
// For non-WAV files, we need to decode (uses more memory)
console.log('Non-WAV file, using AudioContext decode (higher memory)');
const arrayBuffer = await audioFile.arrayBuffer();
const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
sampleRate = audioBuffer.sampleRate;
totalSamples = audioBuffer.length;
const leftChannel = audioBuffer.getChannelData(0);
const rightChannel = audioBuffer.numberOfChannels > 1 ? audioBuffer.getChannelData(1) : leftChannel;
await audioContext.close();
getChunk = async (startSample, numSamples) => {
const end = Math.min(startSample + numSamples, totalSamples);
return {
left: leftChannel.slice(startSample, end),
right: rightChannel.slice(startSample, end),
};
};
}
if (cancelledRef.current) {
setState({ isExporting: false, progress: 0, error: 'Cancelled', stage: 'idle', fps: 0 });
return null;
}
const duration = totalSamples / sampleRate;
const totalFrames = Math.ceil(duration * fps);
const samplesPerFrame = Math.floor(sampleRate / fps);
const fftSize = 2048;
console.log(`Duration: ${duration.toFixed(2)}s, ${totalFrames} frames @ ${fps}fps`);
setState(prev => ({ ...prev, stage: 'rendering', progress: 5 }));
// Create canvas
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { alpha: false, desynchronized: true });
if (!ctx) throw new Error('Could not create canvas context');
// Setup video recording
const stream = canvas.captureStream(0);
const videoTrack = stream.getVideoTracks()[0];
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
? 'video/webm;codecs=vp9'
: 'video/webm;codecs=vp8';
const videoChunks: Blob[] = [];
const recorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: 20_000_000,
});
recorder.ondataavailable = (e) => {
if (e.data.size > 0) videoChunks.push(e.data);
};
// Start recording
recorder.start(1000);
const startTime = performance.now();
let framesProcessed = 0;
// Process frames in batches, loading audio chunks as needed
const chunkSizeFrames = 120; // Process 2 seconds at a time (at 60fps)
const samplesPerChunk = chunkSizeFrames * samplesPerFrame + fftSize;
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += chunkSizeFrames) {
if (cancelledRef.current) {
recorder.stop();
setState({ isExporting: false, progress: 0, error: 'Cancelled', stage: 'idle', fps: 0 });
return null;
}
// Load audio chunk for this batch
const startSample = frameIndex * samplesPerFrame;
const { left: leftChunk, right: rightChunk } = await getChunk(startSample, samplesPerChunk);
// Process frames in this chunk
const endFrame = Math.min(frameIndex + chunkSizeFrames, totalFrames);
for (let f = frameIndex; f < endFrame; f++) {
const localOffset = (f - frameIndex) * samplesPerFrame;
// Extract waveform data for this frame
const leftData = new Uint8Array(fftSize);
const rightData = new Uint8Array(fftSize);
for (let i = 0; i < fftSize; i++) {
const sampleIndex = localOffset + Math.floor((i / fftSize) * samplesPerFrame);
if (sampleIndex >= 0 && sampleIndex < leftChunk.length) {
leftData[i] = Math.round((leftChunk[sampleIndex] * 128) + 128);
rightData[i] = Math.round((rightChunk[sampleIndex] * 128) + 128);
} else {
leftData[i] = 128;
rightData[i] = 128;
}
}
// Draw frame
drawFrame(ctx, width, height, leftData, rightData);
// Capture frame
const track = videoTrack as unknown as { requestFrame?: () => void };
if (track.requestFrame) track.requestFrame();
framesProcessed++;
}
// Update progress
const elapsed = (performance.now() - startTime) / 1000;
const currentFps = Math.round(framesProcessed / elapsed);
const progress = 5 + Math.round((framesProcessed / totalFrames) * 85);
setState(prev => ({ ...prev, progress, fps: currentFps }));
// Yield to main thread
await new Promise(r => setTimeout(r, 0));
}
// Stop recording
await new Promise(r => setTimeout(r, 200));
recorder.stop();
// Wait for recorder to finish
await new Promise<void>(resolve => {
const checkInterval = setInterval(() => {
if (recorder.state === 'inactive') {
clearInterval(checkInterval);
resolve();
}
}, 100);
});
const videoBlob = new Blob(videoChunks, { type: mimeType });
console.log(`Video rendered: ${(videoBlob.size / 1024 / 1024).toFixed(2)} MB`);
setState(prev => ({ ...prev, stage: 'encoding', progress: 92 }));
// Mux audio with video (streaming approach)
const finalBlob = await muxAudioVideo(videoBlob, audioFile, duration, fps);
setState({ isExporting: false, progress: 100, error: null, stage: 'complete', fps: 0 });
console.log(`Export complete: ${(finalBlob.size / 1024 / 1024).toFixed(2)} MB`);
return finalBlob;
} catch (err) {
console.error('Export error:', err);
const message = err instanceof Error ? err.message : 'Export failed';
setState({ isExporting: false, progress: 0, error: message, stage: 'idle', fps: 0 });
return null;
}
}, []);
const cancelExport = useCallback(() => {
cancelledRef.current = true;
}, []);
const downloadBlob = useCallback((blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -343,128 +22,280 @@ export const useOfflineVideoExport = () => {
URL.revokeObjectURL(url);
}, []);
const cancelExport = useCallback(() => {
console.log('Cancel export requested');
cancelledRef.current = true;
setState(prev => ({ ...prev, error: 'Cancelling...' }));
}, []);
const generateVideoWithAudio = useCallback(async (
audioFile: File,
drawFrame: (ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => void,
options: { fps: number; format: 'webm' | 'mp4'; width: number; height: number; quality?: 'low' | 'medium' | 'high'; }
): Promise<Blob | null> => {
console.log('🚀 Starting video export with options:', options);
cancelledRef.current = false;
setState({ isExporting: true, progress: 0, error: null, stage: 'preparing', fps: 0 });
try {
const { fps, width, height, quality = 'medium' } = options;
// Quality settings
const qualitySettings = {
low: { bitrateMultiplier: 0.5, samplesPerFrame: 1024 },
medium: { bitrateMultiplier: 1.0, samplesPerFrame: 2048 },
high: { bitrateMultiplier: 1.5, samplesPerFrame: 4096 }
};
const qualityConfig = qualitySettings[quality];
// Create canvas for rendering
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas not supported');
}
setState(prev => ({ ...prev, stage: 'rendering', progress: 10 }));
// Get supported codecs
const codecs = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/mp4;codecs=h264',
'video/mp4',
'video/webm'
];
let selectedCodec = null;
let videoBitsPerSecond = 2000000; // Default 2Mbps
for (const codec of codecs) {
if (MediaRecorder.isTypeSupported(codec)) {
selectedCodec = codec;
console.log(`✅ Using codec: ${codec}`);
// Adjust bitrate based on codec and quality setting
if (codec.includes('vp9')) {
videoBitsPerSecond = Math.floor(3000000 * qualityConfig.bitrateMultiplier);
} else if (codec.includes('h264')) {
videoBitsPerSecond = Math.floor(4000000 * qualityConfig.bitrateMultiplier);
} else if (codec.includes('vp8')) {
videoBitsPerSecond = Math.floor(2000000 * qualityConfig.bitrateMultiplier);
}
break;
}
}
if (!selectedCodec) {
throw new Error('No video codec supported');
}
// Create audio context for recording
const recordingAudioContext = new AudioContext();
// Resume audio context if suspended
if (recordingAudioContext.state === 'suspended') {
await recordingAudioContext.resume();
}
// Create audio source and destination
const recordingAudioSource = recordingAudioContext.createBufferSource();
recordingAudioSource.buffer = audioBuffer;
recordingAudioSource.loop = false;
const audioDestination = recordingAudioContext.createMediaStreamDestination();
recordingAudioSource.connect(audioDestination);
recordingAudioSource.connect(recordingAudioContext.destination);
// Combine video and audio streams
const combinedStream = new MediaStream();
canvas.captureStream(fps).getVideoTracks().forEach(track => combinedStream.addTrack(track));
audioDestination.stream.getAudioTracks().forEach(track => combinedStream.addTrack(track));
console.log(`✅ Combined stream: ${combinedStream.getVideoTracks().length} video, ${combinedStream.getAudioTracks().length} audio tracks`);
const recorder = new MediaRecorder(combinedStream, {
mimeType: selectedCodec,
videoBitsPerSecond: videoBitsPerSecond,
});
console.log('✅ MediaRecorder created with audio and video');
recorder.start(1000); // 1 second chunks
// Start audio playback synchronized with recording
recordingAudioSource.start(0);
console.log('🔊 Audio playback started for recording');
// Use real audio data if available, otherwise generate mock data
let audioBuffer: AudioBuffer;
let sampleRate: number;
let totalSamples: number;
let duration: number;
try {
// Try to decode the actual uploaded audio file
const arrayBuffer = await audioFile.arrayBuffer();
const audioContext = new AudioContext();
audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
sampleRate = audioBuffer.sampleRate;
totalSamples = audioBuffer.length;
duration = totalSamples / sampleRate;
console.log(`✅ Using real audio: ${duration.toFixed(1)}s, ${totalSamples} samples`);
} catch (audioError) {
console.warn('⚠️ Could not decode audio file, using mock data:', audioError);
// Generate mock audio data
duration = 5.0; // 5 seconds
sampleRate = 44100;
totalSamples = Math.floor(duration * sampleRate);
// Create a proper AudioBuffer for mock data
const mockAudioContext = new AudioContext();
audioBuffer = mockAudioContext.createBuffer(2, totalSamples, sampleRate);
// Fill with sine wave
const leftChannel = audioBuffer.getChannelData(0);
const rightChannel = audioBuffer.getChannelData(1);
for (let i = 0; i < totalSamples; i++) {
const time = i / sampleRate;
const frequency = 440; // A4 note
const value = Math.sin(2 * Math.PI * frequency * time) * 0.5;
leftChannel[i] = value;
rightChannel[i] = value;
}
console.log(`📊 Using mock audio: ${duration.toFixed(1)}s, ${totalSamples} samples`);
}
// Generate animation frames for full audio duration
const totalFrames = Math.ceil(duration * fps);
const samplesPerFrame = Math.min(qualityConfig.samplesPerFrame, Math.floor(totalSamples / totalFrames));
console.log(`🎬 Quality: ${quality}, Frames: ${totalFrames}, Samples/frame: ${samplesPerFrame}, Duration: ${duration.toFixed(1)}s`);
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
if (cancelledRef.current) {
try {
recordingAudioSource.stop();
recordingAudioContext.close();
} catch (e) {}
recorder.stop();
setState({ isExporting: false, progress: 0, error: 'Cancelled', stage: 'idle', fps: 0 });
return null;
}
// Calculate current audio position for this frame
const currentSample = Math.min(frameIndex * samplesPerFrame, totalSamples - samplesPerFrame);
// Get waveform data from actual audio buffer
const leftChannel = audioBuffer.getChannelData(0);
const rightChannel = audioBuffer.numberOfChannels > 1 ? audioBuffer.getChannelData(1) : leftChannel;
// Create waveform data for this frame
const leftData = new Uint8Array(samplesPerFrame);
const rightData = new Uint8Array(samplesPerFrame);
for (let i = 0; i < samplesPerFrame; i++) {
const sampleIndex = currentSample + i;
if (sampleIndex >= 0 && sampleIndex < totalSamples) {
// Convert from -1..1 range to 0..255 range
leftData[i] = Math.round(((leftChannel[sampleIndex] + 1) / 2) * 255);
rightData[i] = Math.round(((rightChannel[sampleIndex] + 1) / 2) * 255);
} else {
leftData[i] = 128;
rightData[i] = 128;
}
}
// Clear canvas
ctx.fillStyle = '#0a0f0a';
ctx.fillRect(0, 0, width, height);
// Draw oscilloscope with mock audio data
try {
drawFrame(ctx, width, height, leftData, rightData);
} catch (drawError) {
console.error('❌ Error in drawFrame:', drawError);
// Fallback: simple waveform
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = 0; x < width; x += 4) {
const sampleIndex = Math.floor((x / width) * samplesPerFrame);
const value = sampleIndex < leftData.length ? leftData[sampleIndex] : 128;
const y = height / 2 + ((value - 128) / 128) * (height / 4);
if (x === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
}
// Add frame info
ctx.fillStyle = '#ffffff';
ctx.font = '16px monospace';
ctx.fillText(`Frame ${frameIndex + 1}/${totalFrames}`, 20, 30);
ctx.fillText(`Time: ${(frameIndex / fps).toFixed(1)}s`, 20, 50);
const progress = 20 + Math.round((frameIndex / totalFrames) * 70);
setState(prev => ({ ...prev, progress }));
if (frameIndex % Math.max(1, Math.floor(totalFrames / 10)) === 0) {
console.log(`📸 Frame ${frameIndex + 1}/${totalFrames} (${progress}%) - Time: ${(frameIndex / fps).toFixed(1)}s`);
}
// Frame timing
await new Promise(resolve => setTimeout(resolve, 1000 / fps));
}
setState(prev => ({ ...prev, progress: 90 }));
console.log('⏹️ Stopping recorder...');
recorder.stop();
try {
recordingAudioSource.stop();
recordingAudioContext.close();
} catch (e) {
console.warn('Error stopping audio:', e);
}
// Wait for completion
await new Promise<void>((resolve) => {
const checkInterval = setInterval(() => {
if (recorder.state === 'inactive') {
clearInterval(checkInterval);
resolve();
}
}, 100);
});
if (chunks.length === 0) {
throw new Error('No video chunks recorded');
}
const videoBlob = new Blob(chunks, { type: selectedCodec });
console.log(`✅ Video created: ${(videoBlob.size / 1024 / 1024).toFixed(2)} MB`);
setState({ isExporting: false, progress: 100, error: null, stage: 'complete', fps: 0 });
return videoBlob;
} catch (error) {
console.error('❌ Export failed:', error);
setState({ isExporting: false, progress: 0, error: error.message || 'Export failed', stage: 'idle', fps: 0 });
return null;
}
}, []);
return {
...state,
generateVideoWithAudio,
cancelExport,
downloadBlob,
};
};
// Improved muxing with better synchronization
async function muxAudioVideo(
videoBlob: Blob,
audioFile: File,
duration: number,
fps: number
): Promise<Blob> {
return new Promise((resolve, reject) => {
const videoUrl = URL.createObjectURL(videoBlob);
const audioUrl = URL.createObjectURL(audioFile);
const video = document.createElement('video');
const audio = document.createElement('audio');
video.src = videoUrl;
video.muted = true;
video.playbackRate = 1; // Normal playback speed
audio.src = audioUrl;
audio.playbackRate = 1;
const cleanup = () => {
URL.revokeObjectURL(videoUrl);
URL.revokeObjectURL(audioUrl);
};
Promise.all([
new Promise<void>((res, rej) => {
video.onloadedmetadata = () => res();
video.onerror = () => rej(new Error('Failed to load video'));
}),
new Promise<void>((res, rej) => {
audio.onloadedmetadata = () => res();
audio.onerror = () => rej(new Error('Failed to load audio'));
}),
]).then(() => {
const audioContext = new AudioContext();
const audioSource = audioContext.createMediaElementSource(audio);
const audioDestination = audioContext.createMediaStreamDestination();
audioSource.connect(audioDestination);
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 1920;
canvas.height = video.videoHeight || 1080;
const ctx = canvas.getContext('2d')!;
const canvasStream = canvas.captureStream(fps);
const combinedStream = new MediaStream([
...canvasStream.getVideoTracks(),
...audioDestination.stream.getAudioTracks(),
]);
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
? 'video/webm;codecs=vp9,opus'
: 'video/webm;codecs=vp8,opus';
const chunks: Blob[] = [];
const recorder = new MediaRecorder(combinedStream, {
mimeType,
videoBitsPerSecond: 20_000_000,
audioBitsPerSecond: 320_000,
});
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = () => {
cleanup();
audioContext.close();
resolve(new Blob(chunks, { type: mimeType }));
};
recorder.onerror = () => {
cleanup();
reject(new Error('Muxing failed'));
};
let lastVideoTime = 0;
const drawLoop = () => {
if (video.paused || video.ended) {
if (video.ended || audio.ended) {
setTimeout(() => recorder.stop(), 100);
return;
}
requestAnimationFrame(drawLoop);
return;
}
// Only draw when video has progressed
if (video.currentTime !== lastVideoTime) {
lastVideoTime = video.currentTime;
ctx.drawImage(video, 0, 0);
}
requestAnimationFrame(drawLoop);
};
recorder.start(100);
// Ensure both start at the same time
video.currentTime = 0;
audio.currentTime = 0;
// Wait for both to be ready to play
Promise.all([video.play(), audio.play()]).then(() => {
drawLoop();
}).catch(err => {
console.error('Playback failed:', err);
cleanup();
reject(err);
});
}).catch(err => {
cleanup();
console.warn('Muxing failed, returning video only:', err);
resolve(videoBlob);
});
});
}
};
+420
View File
@@ -0,0 +1,420 @@
import { useRef, useCallback, useEffect } from 'react';
import type { AudioData } from './useAudioAnalyzer';
export type OscilloscopeMode = 'combined' | 'separate' | 'all';
interface RendererOptions {
mode: OscilloscopeMode;
width: number;
height: number;
phosphorColor: string;
persistence: number;
}
// WebGL shaders for GPU-accelerated rendering
const VERTEX_SHADER = `
attribute vec2 a_position;
uniform vec2 u_resolution;
void main() {
vec2 clipSpace = (a_position / u_resolution) * 2.0 - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
}
`;
const TRACE_FRAGMENT_SHADER = `
precision mediump float;
uniform vec4 u_color;
void main() {
gl_FragColor = u_color;
}
`;
const FADE_VERTEX_SHADER = `
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0, 1);
}
`;
const FADE_FRAGMENT_SHADER = `
precision mediump float;
uniform float u_fade;
void main() {
gl_FragColor = vec4(0.0, 0.031, 0.0, u_fade);
}
`;
function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(gl: WebGLRenderingContext, vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram | null {
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Program link error:', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
return program;
}
interface WebGLResources {
gl: WebGLRenderingContext;
traceProgram: WebGLProgram;
fadeProgram: WebGLProgram;
positionBuffer: WebGLBuffer;
fadeBuffer: WebGLBuffer;
tracePositionLocation: number;
traceResolutionLocation: WebGLUniformLocation;
traceColorLocation: WebGLUniformLocation;
fadePositionLocation: number;
fadeFadeLocation: WebGLUniformLocation;
}
export function useOscilloscopeRenderer() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const glResourcesRef = useRef<WebGLResources | null>(null);
const animationFrameRef = useRef<number | null>(null);
const currentSampleRef = useRef(0);
const initCanvas = useCallback((canvas: HTMLCanvasElement) => {
canvasRef.current = canvas;
const gl = canvas.getContext('webgl', {
preserveDrawingBuffer: true,
antialias: true,
alpha: false
});
if (!gl) {
console.error('WebGL not supported, falling back to 2D');
return;
}
// Create trace shader program
const traceVS = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const traceFS = createShader(gl, gl.FRAGMENT_SHADER, TRACE_FRAGMENT_SHADER);
if (!traceVS || !traceFS) return;
const traceProgram = createProgram(gl, traceVS, traceFS);
if (!traceProgram) return;
// Create fade shader program
const fadeVS = createShader(gl, gl.VERTEX_SHADER, FADE_VERTEX_SHADER);
const fadeFS = createShader(gl, gl.FRAGMENT_SHADER, FADE_FRAGMENT_SHADER);
if (!fadeVS || !fadeFS) return;
const fadeProgram = createProgram(gl, fadeVS, fadeFS);
if (!fadeProgram) return;
// Create buffers
const positionBuffer = gl.createBuffer();
const fadeBuffer = gl.createBuffer();
if (!positionBuffer || !fadeBuffer) return;
// Set up fade quad
gl.bindBuffer(gl.ARRAY_BUFFER, fadeBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
]), gl.STATIC_DRAW);
// Get attribute and uniform locations
const tracePositionLocation = gl.getAttribLocation(traceProgram, 'a_position');
const traceResolutionLocation = gl.getUniformLocation(traceProgram, 'u_resolution');
const traceColorLocation = gl.getUniformLocation(traceProgram, 'u_color');
const fadePositionLocation = gl.getAttribLocation(fadeProgram, 'a_position');
const fadeFadeLocation = gl.getUniformLocation(fadeProgram, 'u_fade');
if (!traceResolutionLocation || !traceColorLocation || !fadeFadeLocation) return;
// Enable blending
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// Initial clear (pure black)
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
glResourcesRef.current = {
gl,
traceProgram,
fadeProgram,
positionBuffer,
fadeBuffer,
tracePositionLocation,
traceResolutionLocation,
traceColorLocation,
fadePositionLocation,
fadeFadeLocation,
};
}, []);
const parseColor = (colorStr: string): [number, number, number, number] => {
// Parse hex color to RGBA
const hex = colorStr.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
return [r, g, b, 1];
};
const drawTrace = useCallback((
gl: WebGLRenderingContext,
resources: WebGLResources,
vertices: number[],
color: [number, number, number, number],
width: number,
height: number
) => {
if (vertices.length < 4) return;
const { traceProgram, positionBuffer, tracePositionLocation, traceResolutionLocation, traceColorLocation } = resources;
gl.useProgram(traceProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(tracePositionLocation);
gl.vertexAttribPointer(tracePositionLocation, 2, gl.FLOAT, false, 0, 0);
gl.uniform2f(traceResolutionLocation, width, height);
gl.uniform4f(traceColorLocation, color[0], color[1], color[2], color[3]);
gl.lineWidth(2);
gl.drawArrays(gl.LINE_STRIP, 0, vertices.length / 2);
}, []);
const drawFrame = useCallback((
audioData: AudioData,
options: RendererOptions,
samplesPerFrame: number
) => {
const resources = glResourcesRef.current;
const canvas = canvasRef.current;
if (!resources || !canvas) return false;
const { gl } = resources;
const { width, height, mode, phosphorColor } = options;
// Clear to pure black each frame (no persistence/ghosting)
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
// Get current sample position
const startSample = currentSampleRef.current;
const endSample = Math.min(startSample + samplesPerFrame, audioData.leftChannel.length);
const color = parseColor(phosphorColor);
const leftColor: [number, number, number, number] = [0, 1, 0, 1]; // Green for left
const rightColor: [number, number, number, number] = [0, 0.8, 1, 1]; // Cyan for right
const xyColor: [number, number, number, number] = [1, 0.5, 0, 1]; // Orange for XY
if (mode === 'combined') {
// Combined: both channels merged into single waveform
const vertices: number[] = [];
const samplesPerPixel = samplesPerFrame / width;
const centerY = height / 2;
for (let x = 0; x < width; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= audioData.leftChannel.length) break;
const sample = (audioData.leftChannel[sampleIndex] + audioData.rightChannel[sampleIndex]) / 2;
const y = centerY - sample * (height * 0.4);
vertices.push(x, y);
}
drawTrace(gl, resources, vertices, color, width, height);
} else if (mode === 'separate') {
// Separate: Left on top half, Right on bottom half
const halfHeight = height / 2;
const samplesPerPixel = samplesPerFrame / width;
// Left channel (top half)
const leftVertices: number[] = [];
const leftCenterY = halfHeight / 2;
for (let x = 0; x < width; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= audioData.leftChannel.length) break;
const sample = audioData.leftChannel[sampleIndex];
const y = leftCenterY - sample * (halfHeight * 0.35);
leftVertices.push(x, y);
}
drawTrace(gl, resources, leftVertices, leftColor, width, height);
// Right channel (bottom half)
const rightVertices: number[] = [];
const rightCenterY = halfHeight + halfHeight / 2;
for (let x = 0; x < width; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= audioData.rightChannel.length) break;
const sample = audioData.rightChannel[sampleIndex];
const y = rightCenterY - sample * (halfHeight * 0.35);
rightVertices.push(x, y);
}
drawTrace(gl, resources, rightVertices, rightColor, width, height);
// Draw divider line
const dividerVertices = [0, halfHeight, width, halfHeight];
drawTrace(gl, resources, dividerVertices, [0.2, 0.2, 0.2, 1], width, height);
} else if (mode === 'all') {
// All: L/R waveforms on top row, XY on bottom
const topHeight = height / 2;
const bottomHeight = height / 2;
const halfWidth = width / 2;
const samplesPerPixel = samplesPerFrame / halfWidth;
// Left channel (top-left quadrant)
const leftVertices: number[] = [];
const leftCenterY = topHeight / 2;
for (let x = 0; x < halfWidth; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= audioData.leftChannel.length) break;
const sample = audioData.leftChannel[sampleIndex];
const y = leftCenterY - sample * (topHeight * 0.35);
leftVertices.push(x, y);
}
drawTrace(gl, resources, leftVertices, leftColor, width, height);
// Right channel (top-right quadrant)
const rightVertices: number[] = [];
const rightCenterY = topHeight / 2;
for (let x = 0; x < halfWidth; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= audioData.rightChannel.length) break;
const sample = audioData.rightChannel[sampleIndex];
const y = rightCenterY - sample * (topHeight * 0.35);
rightVertices.push(halfWidth + x, y);
}
drawTrace(gl, resources, rightVertices, rightColor, width, height);
// XY mode (bottom half, centered)
const xyVertices: number[] = [];
const xyCenterX = width / 2;
const xyCenterY = topHeight + bottomHeight / 2;
const xyScale = Math.min(halfWidth, bottomHeight) * 0.35;
for (let i = startSample; i < endSample; i++) {
const x = xyCenterX + audioData.leftChannel[i] * xyScale;
const y = xyCenterY - audioData.rightChannel[i] * xyScale;
xyVertices.push(x, y);
}
drawTrace(gl, resources, xyVertices, xyColor, width, height);
// Draw divider lines
drawTrace(gl, resources, [0, topHeight, width, topHeight], [0.2, 0.2, 0.2, 1], width, height);
drawTrace(gl, resources, [halfWidth, 0, halfWidth, topHeight], [0.2, 0.2, 0.2, 1], width, height);
}
// Update sample position
currentSampleRef.current = endSample;
return endSample >= audioData.leftChannel.length;
}, [drawTrace]);
const draw2DGraticule = (canvas: HTMLCanvasElement, width: number, height: number) => {
// Get 2D context for graticule overlay
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.strokeStyle = 'rgba(0, 100, 0, 0.3)';
ctx.lineWidth = 1;
const divisions = 8;
const cellWidth = width / divisions;
const cellHeight = height / divisions;
for (let i = 0; i <= divisions; i++) {
ctx.beginPath();
ctx.moveTo(i * cellWidth, 0);
ctx.lineTo(i * cellWidth, height);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i * cellHeight);
ctx.lineTo(width, i * cellHeight);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(0, 150, 0, 0.5)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, height / 2);
ctx.lineTo(width, height / 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(width / 2, 0);
ctx.lineTo(width / 2, height);
ctx.stroke();
};
const resetPlayback = useCallback(() => {
currentSampleRef.current = 0;
const resources = glResourcesRef.current;
if (resources) {
const { gl } = resources;
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
}
}, []);
const stopAnimation = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
}, []);
const getCurrentSample = useCallback(() => currentSampleRef.current, []);
useEffect(() => {
return () => {
stopAnimation();
// Clean up WebGL resources
if (glResourcesRef.current) {
const { gl, traceProgram, fadeProgram, positionBuffer, fadeBuffer } = glResourcesRef.current;
gl.deleteProgram(traceProgram);
gl.deleteProgram(fadeProgram);
gl.deleteBuffer(positionBuffer);
gl.deleteBuffer(fadeBuffer);
glResourcesRef.current = null;
}
};
}, [stopAnimation]);
return {
canvasRef,
initCanvas,
drawFrame,
resetPlayback,
stopAnimation,
getCurrentSample,
};
}
+526
View File
@@ -0,0 +1,526 @@
import { useState, useCallback, useRef } from 'react';
import type { AudioData } from './useAudioAnalyzer';
import type { OscilloscopeMode } from './useOscilloscopeRenderer';
interface ExportOptions {
width: number;
height: number;
fps: number;
mode: OscilloscopeMode;
audioFile: File;
}
// WebGL shaders
const VERTEX_SHADER = `
attribute vec2 a_position;
uniform vec2 u_resolution;
void main() {
vec2 clipSpace = (a_position / u_resolution) * 2.0 - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
}
`;
const TRACE_FRAGMENT_SHADER = `
precision mediump float;
uniform vec4 u_color;
void main() {
gl_FragColor = u_color;
}
`;
function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(gl: WebGLRenderingContext, vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram | null {
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Program link error:', gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
return program;
}
export function useVideoExporter() {
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [exportedUrl, setExportedUrl] = useState<string | null>(null);
const cancelRef = useRef(false);
const exportVideo = useCallback(async (
audioData: AudioData,
audioFile: File,
options: ExportOptions
) => {
setIsExporting(true);
setProgress(0);
setExportedUrl(null);
cancelRef.current = false;
const { width, height, fps, mode } = options;
const totalSamples = audioData.leftChannel.length;
const samplesPerFrame = Math.floor(audioData.sampleRate / fps);
const log = (...args: unknown[]) => {
console.log('[useVideoExporter]', ...args);
};
log('export start', {
width,
height,
fps,
mode,
analyzerSampleRate: audioData.sampleRate,
totalSamples,
samplesPerFrame,
estimatedDuration: totalSamples / audioData.sampleRate,
});
// Create WebGL canvas for rendering
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const gl = canvas.getContext('webgl', {
preserveDrawingBuffer: true,
antialias: true,
alpha: false,
});
if (!gl) {
console.error('WebGL not available');
setIsExporting(false);
return null;
}
// Set up WebGL program
const traceVS = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const traceFS = createShader(gl, gl.FRAGMENT_SHADER, TRACE_FRAGMENT_SHADER);
if (!traceVS || !traceFS) {
setIsExporting(false);
return null;
}
const traceProgram = createProgram(gl, traceVS, traceFS);
if (!traceProgram) {
setIsExporting(false);
return null;
}
const positionBuffer = gl.createBuffer();
if (!positionBuffer) {
setIsExporting(false);
return null;
}
const tracePositionLocation = gl.getAttribLocation(traceProgram, 'a_position');
const traceResolutionLocation = gl.getUniformLocation(traceProgram, 'u_resolution');
const traceColorLocation = gl.getUniformLocation(traceProgram, 'u_color');
if (!traceResolutionLocation || !traceColorLocation) {
setIsExporting(false);
return null;
}
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.viewport(0, 0, width, height);
// Helper to draw a trace
const drawTrace = (vertices: number[], color: [number, number, number, number]) => {
if (vertices.length < 4) return;
gl.useProgram(traceProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(tracePositionLocation);
gl.vertexAttribPointer(tracePositionLocation, 2, gl.FLOAT, false, 0, 0);
gl.uniform2f(traceResolutionLocation, width, height);
gl.uniform4f(traceColorLocation, color[0], color[1], color[2], color[3]);
gl.lineWidth(2);
gl.drawArrays(gl.LINE_STRIP, 0, vertices.length / 2);
};
// Function to render a single frame at a specific sample position
const renderFrameAtSample = (startSample: number): void => {
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
const endSample = Math.min(startSample + samplesPerFrame, totalSamples);
const leftColor: [number, number, number, number] = [0, 1, 0, 1];
const rightColor: [number, number, number, number] = [0, 0.8, 1, 1];
const xyColor: [number, number, number, number] = [1, 0.5, 0, 1];
const dividerColor: [number, number, number, number] = [0.2, 0.2, 0.2, 1];
if (mode === 'combined') {
const vertices: number[] = [];
const samplesPerPixel = samplesPerFrame / width;
const centerY = height / 2;
for (let x = 0; x < width; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= totalSamples) break;
const sample = (audioData.leftChannel[sampleIndex] + audioData.rightChannel[sampleIndex]) / 2;
const y = centerY - sample * (height * 0.4);
vertices.push(x, y);
}
drawTrace(vertices, leftColor);
} else if (mode === 'separate') {
const halfHeight = height / 2;
const samplesPerPixel = samplesPerFrame / width;
// Left channel (top half)
const leftVertices: number[] = [];
const leftCenterY = halfHeight / 2;
for (let x = 0; x < width; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= totalSamples) break;
const sample = audioData.leftChannel[sampleIndex];
const y = leftCenterY - sample * (halfHeight * 0.35);
leftVertices.push(x, y);
}
drawTrace(leftVertices, leftColor);
// Right channel (bottom half)
const rightVertices: number[] = [];
const rightCenterY = halfHeight + halfHeight / 2;
for (let x = 0; x < width; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= totalSamples) break;
const sample = audioData.rightChannel[sampleIndex];
const y = rightCenterY - sample * (halfHeight * 0.35);
rightVertices.push(x, y);
}
drawTrace(rightVertices, rightColor);
// Divider
drawTrace([0, halfHeight, width, halfHeight], dividerColor);
} else if (mode === 'all') {
const topHeight = height / 2;
const bottomHeight = height / 2;
const halfWidth = width / 2;
const samplesPerPixel = samplesPerFrame / halfWidth;
// Left channel (top-left)
const leftVertices: number[] = [];
const leftCenterY = topHeight / 2;
for (let x = 0; x < halfWidth; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= totalSamples) break;
const sample = audioData.leftChannel[sampleIndex];
const y = leftCenterY - sample * (topHeight * 0.35);
leftVertices.push(x, y);
}
drawTrace(leftVertices, leftColor);
// Right channel (top-right)
const rightVertices: number[] = [];
const rightCenterY = topHeight / 2;
for (let x = 0; x < halfWidth; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= totalSamples) break;
const sample = audioData.rightChannel[sampleIndex];
const y = rightCenterY - sample * (topHeight * 0.35);
rightVertices.push(halfWidth + x, y);
}
drawTrace(rightVertices, rightColor);
// XY mode (bottom half)
const xyVertices: number[] = [];
const xyCenterX = width / 2;
const xyCenterY = topHeight + bottomHeight / 2;
const xyScale = Math.min(halfWidth, bottomHeight) * 0.35;
for (let i = startSample; i < endSample; i++) {
const x = xyCenterX + audioData.leftChannel[i] * xyScale;
const y = xyCenterY - audioData.rightChannel[i] * xyScale;
xyVertices.push(x, y);
}
drawTrace(xyVertices, xyColor);
// Dividers
drawTrace([0, topHeight, width, topHeight], dividerColor);
drawTrace([halfWidth, 0, halfWidth, topHeight], dividerColor);
}
};
// Capture stream at the target FPS
const videoStream = canvas.captureStream(fps);
// Decode audio
let audioContext: AudioContext;
try {
audioContext = new AudioContext({ sampleRate: audioData.sampleRate });
} catch {
log('AudioContext({sampleRate}) failed; falling back to default AudioContext()');
audioContext = new AudioContext();
}
await audioContext.resume();
const audioArrayBuffer = await audioFile.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(audioArrayBuffer);
log('decoded audio', {
ctxSampleRate: audioContext.sampleRate,
duration: audioBuffer.duration,
channels: audioBuffer.numberOfChannels,
});
const audioSource = audioContext.createBufferSource();
audioSource.buffer = audioBuffer;
const audioDestination = audioContext.createMediaStreamDestination();
audioSource.connect(audioDestination);
const combinedStream = new MediaStream([
...videoStream.getVideoTracks(),
...audioDestination.stream.getAudioTracks(),
]);
// Prefer VP8 for broad compatibility
let mimeType = 'video/webm;codecs=vp8,opus';
if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = 'video/webm;codecs=vp9,opus';
}
if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = 'video/webm';
}
log('MediaRecorder setup', {
requestedMimeType: mimeType,
videoBitsPerSecond: 8000000,
audioBitsPerSecond: 256000,
});
const mediaRecorder = new MediaRecorder(combinedStream, {
mimeType,
videoBitsPerSecond: 8000000,
audioBitsPerSecond: 256000,
});
const chunks: Blob[] = [];
let chunkBytes = 0;
mediaRecorder.onstart = () =>
log('MediaRecorder onstart', { state: mediaRecorder.state, mimeType: mediaRecorder.mimeType });
mediaRecorder.ondataavailable = (e) => {
const size = e?.data?.size ?? 0;
log('MediaRecorder ondataavailable', {
size,
type: e?.data?.type,
recorderState: mediaRecorder.state,
});
if (e.data && e.data.size > 0) {
chunks.push(e.data);
chunkBytes += e.data.size;
}
};
return new Promise<string>((resolve, reject) => {
let stopped = false;
let stopReason: string = 'unknown';
let lastRenderedFrame = -1;
let lastLoggedSecond = -1;
let rafId = 0;
let safetyTimer: number | null = null;
const stopRecorder = (reason: string) => {
if (stopped) return;
stopped = true;
stopReason = reason;
log('stopRecorder()', {
reason,
recorderState: mediaRecorder.state,
chunks: chunks.length,
chunkBytes,
});
if (rafId) cancelAnimationFrame(rafId);
if (safetyTimer) window.clearTimeout(safetyTimer);
if (reason === 'cancel') {
try {
audioSource.stop();
} catch {
// ignore
}
}
try {
if (mediaRecorder.state === 'recording') {
log('calling mediaRecorder.stop()');
mediaRecorder.stop();
}
} catch (e) {
log('mediaRecorder.stop() failed', e);
}
};
audioSource.onended = () => {
log('audioSource.onended');
try {
const endSample = Math.max(0, totalSamples - samplesPerFrame);
renderFrameAtSample(endSample);
} catch (e) {
log('final frame render failed', e);
}
stopRecorder('audio_ended');
};
mediaRecorder.onstop = async () => {
log('MediaRecorder onstop', { stopReason, chunks: chunks.length, chunkBytes });
// Cleanup WebGL
gl.deleteProgram(traceProgram);
gl.deleteBuffer(positionBuffer);
try {
await audioContext.close();
} catch {
// ignore
}
try {
combinedStream.getTracks().forEach((t) => t.stop());
} catch {
// ignore
}
const finalMime = mediaRecorder.mimeType || mimeType;
const blob = new Blob(chunks, { type: finalMime });
log('final blob', {
mime: finalMime,
blobSize: blob.size,
chunks: chunks.length,
chunkBytes,
});
if (blob.size === 0) {
setIsExporting(false);
reject(new Error('Export failed: empty recording blob'));
return;
}
const url = URL.createObjectURL(blob);
setExportedUrl(url);
setIsExporting(false);
setProgress(100);
resolve(url);
};
mediaRecorder.onerror = (e) => {
log('MediaRecorder onerror', e);
setIsExporting(false);
reject(e);
};
// Start without timeslice - this creates a single continuous WebM file
mediaRecorder.start();
log('mediaRecorder.start() called', { state: mediaRecorder.state, mimeType: mediaRecorder.mimeType });
const exportStart = audioContext.currentTime;
audioSource.start(0);
log('audioSource.start() called', { exportStart, duration: audioBuffer.duration });
// Safety timeout: for very long files (6+ hours = 21600+ seconds), add generous buffer
const safetyDuration = Math.ceil(audioBuffer.duration * 1000 + 30000); // 30s buffer
log('safety timer set', { safetyDuration, durationSeconds: audioBuffer.duration });
safetyTimer = window.setTimeout(() => {
log('safety timeout hit');
stopRecorder('safety_timeout');
}, safetyDuration);
const renderLoop = () => {
if (stopped) return;
if (cancelRef.current) {
log('cancelRef triggered');
stopRecorder('cancel');
return;
}
const t = Math.max(0, audioContext.currentTime - exportStart);
// Heartbeat every 10 seconds for long exports
const sec = Math.floor(t / 10) * 10;
if (sec !== lastLoggedSecond && sec > 0) {
lastLoggedSecond = sec;
log('heartbeat', {
t: t.toFixed(1),
duration: audioBuffer.duration.toFixed(1),
percentComplete: ((t / audioBuffer.duration) * 100).toFixed(1),
recorderState: mediaRecorder.state,
chunks: chunks.length,
chunkBytes,
});
}
// Guard: if audio should have ended but didn't, stop
if (t > audioBuffer.duration + 2) {
log('duration guard hit', { t, duration: audioBuffer.duration });
stopRecorder('duration_guard');
return;
}
const frameIndex = Math.floor(t * fps);
if (frameIndex !== lastRenderedFrame) {
const startSample = Math.min(frameIndex * samplesPerFrame, totalSamples - 1);
renderFrameAtSample(startSample);
lastRenderedFrame = frameIndex;
// Update progress less frequently for performance
if (frameIndex % 60 === 0) {
setProgress(Math.min(99, Math.floor((startSample / totalSamples) * 100)));
}
}
rafId = requestAnimationFrame(renderLoop);
};
rafId = requestAnimationFrame(renderLoop);
});
}, []);
const reset = useCallback(() => {
if (exportedUrl) {
URL.revokeObjectURL(exportedUrl);
}
cancelRef.current = true;
setExportedUrl(null);
setProgress(0);
}, [exportedUrl]);
return {
isExporting,
progress,
exportedUrl,
exportVideo,
reset,
};
}