b42ff297eb
- Prevent duplicate piece shapes when generating next pieces - Enhanced randomTetromino() with excludeShape parameter - Update piece placement logic to avoid visual duplicates - Improve initial state generation for both players - Add P1 and P2 next piece previews in 2P mode
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
// Streaming audio processor for large files using WebCodecs API
|
|
// Handles files > 1GB without loading entire file into memory
|
|
|
|
export interface DecodedAudioFrame {
|
|
leftChannel: Float32Array;
|
|
rightChannel: Float32Array;
|
|
sampleRate: number;
|
|
timestamp: number;
|
|
}
|
|
|
|
export interface AudioProcessorConfig {
|
|
chunkSize: number;
|
|
bufferSize: number; // seconds of audio to keep in memory
|
|
sampleRate: number;
|
|
channels: number;
|
|
}
|
|
|
|
export class StreamingAudioProcessor {
|
|
private audioContext: AudioContext;
|
|
private decoder: AudioDecoder | null = null;
|
|
private fileBufferQueue: Float32Array[] = [];
|
|
private fileReader: FileReader | null = null;
|
|
private totalSamplesProcessed = 0;
|
|
private sampleRate = 48000;
|
|
private channels = 2;
|
|
private isProcessing = false;
|
|
private abortController: AbortController | null = null;
|
|
|
|
constructor(config: AudioProcessorConfig) {
|
|
this.audioContext = new AudioContext();
|
|
this.sampleRate = config.sampleRate;
|
|
this.channels = config.channels;
|
|
}
|
|
|
|
async initializeDecoder(file: File): Promise<void> {
|
|
// Detect codec from file type
|
|
const codec = this.getCodecFromFile(file);
|
|
console.log(`Initializing decoder for codec: ${codec}`);
|
|
|
|
// Check browser support
|
|
if (!this.isWebCodecsSupported()) {
|
|
throw new Error(`WebCodecs AudioDecoder not supported in this browser. codec: ${codec}`);
|
|
}
|
|
|
|
// FLAC is not widely supported by WebCodecs, use traditional decode
|
|
if (codec === 'flac') {
|
|
console.warn('FLAC codec not supported by WebCodecs, using traditional decode (may have memory limits)');
|
|
throw new Error('FLAC codec requires traditional decode. For large FLAC files, consider converting to MP3/OGG for streaming support.');
|
|
}
|
|
|
|
// Check codec support
|
|
const codecSupport = await AudioDecoder.isConfigSupported({
|
|
codec,
|
|
sampleRate: this.sampleRate,
|
|
numberOfChannels: this.channels
|
|
});
|
|
|
|
console.log(`Codec ${codec} support:`, codecSupport);
|
|
|
|
if (!codecSupport.supported) {
|
|
throw new Error(`Codec ${codec} not supported by this browser`);
|
|
}
|
|
|
|
// Create decoder
|
|
this.decoder = new AudioDecoder({
|
|
output: (audioData) => {
|
|
this.handleDecodedFrame(audioData);
|
|
},
|
|
error: (error) => {
|
|
console.error('AudioDecoder error:', error);
|
|
throw new Error(`Decoding failed: ${error}`);
|
|
}
|
|
});
|
|
|
|
await this.decoder.configure({
|
|
codec,
|
|
sampleRate: this.sampleRate,
|
|
numberOfChannels: this.channels
|
|
});
|
|
|
|
console.log(`AudioDecoder configured successfully for ${codec}`);
|
|
}
|
|
|
|
private getCodecFromFile(file: File): string {
|
|
const extension = file.name.split('.').pop()?.toLowerCase();
|
|
const mimeType = file.type;
|
|
|
|
// Map file extensions to codec strings
|
|
const codecMap: Record<string, string> = {
|
|
'mp3': 'mp3',
|
|
'flac': 'flac',
|
|
'wav': 'pcm',
|
|
'ogg': 'opus',
|
|
'm4a': 'aac',
|
|
'aac': 'aac'
|
|
};
|
|
|
|
// Try extension first
|
|
if (extension && codecMap[extension]) {
|
|
return codecMap[extension];
|
|
}
|
|
|
|
// Try MIME type
|
|
if (mimeType) {
|
|
if (mimeType.includes('mp3')) return 'mp3';
|
|
if (mimeType.includes('flac')) return 'flac';
|
|
if (mimeType.includes('wav')) return 'pcm';
|
|
if (mimeType.includes('opus')) return 'opus';
|
|
if (mimeType.includes('aac')) return 'aac';
|
|
}
|
|
|
|
throw new Error(`Unsupported file format: ${extension || 'unknown'}`);
|
|
}
|
|
|
|
private isWebCodecsSupported(): boolean {
|
|
return 'AudioDecoder' in window && 'EncodedAudioChunk' in window;
|
|
}
|
|
|
|
private handleDecodedFrame(audioData: AudioData): void {
|
|
// Convert AudioData to Float32Array channels
|
|
const leftChannel = new Float32Array(audioData.numberOfFrames);
|
|
const rightChannel = new Float32Array(audioData.numberOfFrames);
|
|
|
|
audioData.copyTo(leftChannel, { planeIndex: 0 });
|
|
audioData.copyTo(rightChannel, { planeIndex: 1 });
|
|
|
|
// Add to buffer queue
|
|
this.fileBufferQueue.push(leftChannel, rightChannel);
|
|
this.totalSamplesProcessed += audioData.numberOfFrames;
|
|
|
|
// Limit buffer size to prevent memory growth
|
|
const maxBufferFrames = this.sampleRate * 30; // Keep 30 seconds max
|
|
const currentBufferFrames = this.fileBufferQueue.length / 2 * leftChannel.length;
|
|
|
|
if (currentBufferFrames > maxBufferFrames) {
|
|
// Remove oldest data to maintain buffer limit
|
|
const framesToRemove = currentBufferFrames - maxBufferFrames;
|
|
const chunksToRemove = Math.ceil(framesToRemove / leftChannel.length) * 2;
|
|
this.fileBufferQueue.splice(0, chunksToRemove);
|
|
}
|
|
}
|
|
|
|
async processLargeFile(file: File): Promise<{
|
|
sampleRate: number;
|
|
duration: number;
|
|
totalSamples: number;
|
|
getBufferedSamples: (offset: number, count: number) => { left: Float32Array; right: Float32Array } | null;
|
|
}> {
|
|
console.log(`Starting streaming processing for ${file.name} (${(file.size / (1024 * 1024 * 1024)).toFixed(1)}GB)`);
|
|
|
|
this.isProcessing = true;
|
|
this.abortController = new AbortController();
|
|
this.fileBufferQueue = [];
|
|
this.totalSamplesProcessed = 0;
|
|
|
|
try {
|
|
await this.initializeDecoder(file);
|
|
|
|
// Get metadata first
|
|
const metadata = await this.extractMetadata(file);
|
|
console.log(`Audio metadata: ${metadata.duration.toFixed(1)}s, sampleRate: ${metadata.sampleRate}`);
|
|
|
|
// Process file in chunks
|
|
await this.streamFileChunks(file);
|
|
|
|
// Flush remaining decoder data
|
|
if (this.decoder) {
|
|
await this.decoder.flush();
|
|
}
|
|
|
|
const duration = this.totalSamplesProcessed / this.sampleRate;
|
|
|
|
console.log(`✅ Streaming processing complete: ${duration.toFixed(1)}s, ${this.totalSamplesProcessed.toLocaleString()} samples`);
|
|
|
|
return {
|
|
sampleRate: this.sampleRate,
|
|
duration,
|
|
totalSamples: this.totalSamplesProcessed,
|
|
getBufferedSamples: (offset: number, count: number) => {
|
|
return this.getSamplesFromBuffer(offset, count);
|
|
}
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('❌ Streaming processing failed:', error);
|
|
throw error;
|
|
} finally {
|
|
this.isProcessing = false;
|
|
this.cleanup();
|
|
}
|
|
}
|
|
|
|
private async extractMetadata(file: File): Promise<{ duration: number; sampleRate: number }> {
|
|
// Create temporary audio element to get basic metadata
|
|
return new Promise((resolve, reject) => {
|
|
const audio = new Audio();
|
|
const objectUrl = URL.createObjectURL(file);
|
|
|
|
audio.addEventListener('loadedmetadata', () => {
|
|
URL.revokeObjectURL(objectUrl);
|
|
resolve({
|
|
duration: audio.duration || 0,
|
|
sampleRate: this.sampleRate
|
|
});
|
|
});
|
|
|
|
audio.addEventListener('error', () => {
|
|
URL.revokeObjectURL(objectUrl);
|
|
reject(new Error('Failed to load audio metadata'));
|
|
});
|
|
|
|
audio.src = objectUrl;
|
|
audio.load();
|
|
});
|
|
}
|
|
|
|
private async streamFileChunks(file: File): Promise<void> {
|
|
const chunkSize = 64 * 1024; // 64KB chunks
|
|
let offset = 0;
|
|
|
|
console.log(`Starting chunked reading with ${chunkSize} byte chunks...`);
|
|
|
|
while (offset < file.size && !this.abortController?.signal.aborted) {
|
|
const chunk = file.slice(offset, offset + chunkSize);
|
|
|
|
try {
|
|
const arrayBuffer = await chunk.arrayBuffer();
|
|
|
|
if (this.decoder && arrayBuffer.byteLength > 0) {
|
|
// Create EncodedAudioChunk
|
|
const encodedChunk = new EncodedAudioChunk({
|
|
type: offset === 0 ? 'key' : 'delta', // First chunk is key frame
|
|
timestamp: (offset / file.size) * 1000000, // microseconds
|
|
data: arrayBuffer
|
|
});
|
|
|
|
// Feed to decoder
|
|
this.decoder.decode(encodedChunk);
|
|
}
|
|
|
|
offset += arrayBuffer.byteLength;
|
|
|
|
// Progress logging for large files
|
|
if (offset % (10 * 1024 * 1024) < chunkSize) { // Every 10MB
|
|
const progress = (offset / file.size) * 100;
|
|
console.log(`Processed ${(offset / (1024 * 1024)).toFixed(1)}MB (${progress.toFixed(1)}%)`);
|
|
}
|
|
|
|
} catch (chunkError) {
|
|
console.warn(`Failed to process chunk at offset ${offset}:`, chunkError);
|
|
// Continue with next chunk rather than failing completely
|
|
}
|
|
}
|
|
}
|
|
|
|
private getSamplesFromBuffer(offset: number, count: number): { left: Float32Array; right: Float32Array } | null {
|
|
if (this.fileBufferQueue.length === 0) return null;
|
|
|
|
const samplesPerChannel = this.fileBufferQueue.length / 2;
|
|
const samplesPerChunk = this.fileBufferQueue[0]?.length || 0;
|
|
const samplesAvailable = Math.min(count, samplesPerChannel * samplesPerChunk);
|
|
|
|
if (samplesAvailable <= 0) return null;
|
|
|
|
const leftResult = new Float32Array(samplesAvailable);
|
|
const rightResult = new Float32Array(samplesAvailable);
|
|
|
|
// Copy samples from buffer queue
|
|
let samplesCopied = 0;
|
|
const bufferIndex = Math.floor(offset / samplesPerChunk) * 2;
|
|
|
|
for (let i = bufferIndex; i < this.fileBufferQueue.length && samplesCopied < samplesAvailable; i += 2) {
|
|
const leftChunk = this.fileBufferQueue[i];
|
|
const rightChunk = this.fileBufferQueue[i + 1];
|
|
|
|
const copyLength = Math.min(leftChunk.length, samplesAvailable - samplesCopied);
|
|
|
|
leftResult.set(leftChunk.subarray(0, copyLength), samplesCopied);
|
|
rightResult.set(rightChunk.subarray(0, copyLength), samplesCopied);
|
|
|
|
samplesCopied += copyLength;
|
|
}
|
|
|
|
return {
|
|
left: leftResult,
|
|
right: rightResult
|
|
};
|
|
}
|
|
|
|
cleanup(): void {
|
|
if (this.decoder) {
|
|
try {
|
|
this.decoder.close();
|
|
} catch (e) {
|
|
// Ignore cleanup errors
|
|
}
|
|
this.decoder = null;
|
|
}
|
|
|
|
if (this.abortController) {
|
|
this.abortController.abort();
|
|
this.abortController = null;
|
|
}
|
|
|
|
this.fileBufferQueue = [];
|
|
}
|
|
|
|
abort(): void {
|
|
if (this.abortController) {
|
|
this.abortController.abort();
|
|
}
|
|
}
|
|
|
|
get isReady(): boolean {
|
|
return this.decoder?.state === 'configured';
|
|
}
|
|
|
|
get progress(): number {
|
|
if (this.fileBufferQueue.length === 0) return 0;
|
|
return this.totalSamplesProcessed / this.sampleRate; // seconds processed
|
|
}
|
|
} |