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:
2026-01-06 21:54:11 +01:00
parent 4a1879354b
commit b42ff297eb
13 changed files with 2679 additions and 18 deletions
+322
View File
@@ -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
}
}