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:
Regular → Executable
+50
-227
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
Executable
+420
@@ -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,
|
||||
};
|
||||
}
|
||||
Executable
+526
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user