Fix Tetris 2P preview block synchronization issue
- 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
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
interface PerformanceMetrics {
|
||||
frameRate: number;
|
||||
memoryUsage?: number;
|
||||
renderTime: number;
|
||||
dropCount: number;
|
||||
}
|
||||
|
||||
export function usePerformanceMonitor() {
|
||||
const frameCount = useRef(0);
|
||||
const lastTime = useRef(performance.now());
|
||||
const dropCount = useRef(0);
|
||||
const renderTimes = useRef<number[]>([]);
|
||||
const maxRenderTimeSamples = 60; // Keep last 60 frames
|
||||
|
||||
const recordFrame = useCallback(() => {
|
||||
const now = performance.now();
|
||||
const deltaTime = now - lastTime.current;
|
||||
|
||||
frameCount.current++;
|
||||
|
||||
// Track render time
|
||||
renderTimes.current.push(deltaTime);
|
||||
if (renderTimes.current.length > maxRenderTimeSamples) {
|
||||
renderTimes.current.shift();
|
||||
}
|
||||
|
||||
// Detect frame drops (should be ~16.67ms for 60fps)
|
||||
if (deltaTime > 20) { // Allow some tolerance
|
||||
dropCount.current++;
|
||||
}
|
||||
|
||||
lastTime.current = now;
|
||||
|
||||
// Calculate metrics every second
|
||||
if (frameCount.current % 60 === 0) {
|
||||
const avgRenderTime = renderTimes.current.reduce((a, b) => a + b, 0) / renderTimes.current.length;
|
||||
const fps = 1000 / avgRenderTime;
|
||||
|
||||
const metrics: PerformanceMetrics = {
|
||||
frameRate: fps,
|
||||
memoryUsage: (performance as any).memory ?
|
||||
Math.round((performance as any).memory.usedJSHeapSize / (1024 * 1024)) : undefined,
|
||||
renderTime: avgRenderTime,
|
||||
dropCount: dropCount.current
|
||||
};
|
||||
|
||||
// Log performance warnings
|
||||
if (fps < 30) {
|
||||
console.warn(`⚠️ Low FPS: ${fps.toFixed(1)}, Drops: ${dropCount.current}, Memory: ${metrics.memoryUsage}MB`);
|
||||
}
|
||||
|
||||
if (metrics.memoryUsage && metrics.memoryUsage > 2000) { // > 2GB
|
||||
console.warn(`⚠️ High memory usage: ${metrics.memoryUsage}MB, FPS: ${fps.toFixed(1)}`);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
frameCount.current = 0;
|
||||
lastTime.current = performance.now();
|
||||
dropCount.current = 0;
|
||||
renderTimes.current = [];
|
||||
}, []);
|
||||
|
||||
const getCurrentMetrics = useCallback((): PerformanceMetrics => {
|
||||
const avgRenderTime = renderTimes.current.length > 0
|
||||
? renderTimes.current.reduce((a, b) => a + b, 0) / renderTimes.current.length
|
||||
: 16.67; // Default for 60fps
|
||||
|
||||
return {
|
||||
frameRate: 1000 / avgRenderTime,
|
||||
memoryUsage: (performance as any).memory ?
|
||||
Math.round((performance as any).memory.usedJSHeapSize / (1024 * 1024)) : undefined,
|
||||
renderTime: avgRenderTime,
|
||||
dropCount: dropCount.current
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
recordFrame,
|
||||
reset,
|
||||
getCurrentMetrics
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user