UNPKG

z-web-audio-stream

Version:

iOS Safari-safe Web Audio streaming with separated download/storage optimization, instant playback, and memory management

243 lines 9.61 kB
// StreamingAssembler.ts // Assembles download chunks into storage chunks and playback buffers // Bridges the gap between network-optimized downloads and storage-optimized chunks /** * Streaming assembler that converts network-optimized download chunks * into storage-optimized chunks and playback-ready buffers * * Key features: * - Assembles small download chunks (64KB-512KB) into larger storage chunks (1-3MB) * - Creates optimal first chunk for instant playback (256-384KB) * - Streams assembly - doesn't wait for all downloads to complete * - Memory efficient - releases download chunks after assembly * - iOS Safari optimized chunk sizes */ export class StreamingAssembler { options; downloadChunks = new Map(); assembledChunks = new Map(); totalExpectedSize = 0; isPlaybackReady = false; // Assembly state tracking nextStorageIndex = 0; currentAssemblyBuffer = null; currentAssemblySize = 0; currentDownloadChunks = []; constructor(options) { this.options = options; console.log(`[StreamingAssembler] Initialized with storage chunk size: ${(options.storageChunkSize / 1024 / 1024).toFixed(1)}MB, playback chunk size: ${(options.playbackChunkSize / 1024).toFixed(0)}KB`); } /** * Initialize assembly for a specific total file size */ initialize(totalSize) { this.totalExpectedSize = totalSize; this.downloadChunks.clear(); this.assembledChunks.clear(); this.nextStorageIndex = 0; this.currentAssemblyBuffer = null; this.currentAssemblySize = 0; this.currentDownloadChunks = []; this.isPlaybackReady = false; console.log(`[StreamingAssembler] Initialized for ${(totalSize / 1024 / 1024).toFixed(2)}MB file`); } /** * Add a downloaded chunk for assembly */ addDownloadChunk(chunk) { this.downloadChunks.set(chunk.index, chunk); // Process chunks in order as they become available this.processAvailableChunks(); } /** * Process all available sequential chunks */ processAvailableChunks() { // Find the next sequential chunk we can process let nextIndex = this.getNextSequentialIndex(); while (this.downloadChunks.has(nextIndex)) { const chunk = this.downloadChunks.get(nextIndex); this.processDownloadChunk(chunk); this.downloadChunks.delete(nextIndex); // Free memory nextIndex++; } } /** * Get the next sequential download chunk index we're waiting for */ getNextSequentialIndex() { // Count total chunks processed so far let processedChunks = 0; for (const assembledChunk of this.assembledChunks.values()) { processedChunks += assembledChunk.downloadChunks.length; } // Add current assembly buffer chunks processedChunks += this.currentDownloadChunks.length; return processedChunks; } /** * Process a single download chunk into the assembly buffer */ processDownloadChunk(chunk) { // Add chunk to current assembly this.currentDownloadChunks.push(chunk); // Combine with existing assembly buffer const newTotalSize = this.currentAssemblySize + chunk.data.byteLength; const combined = new ArrayBuffer(newTotalSize); const combinedView = new Uint8Array(combined); // Copy existing data if (this.currentAssemblyBuffer) { const existingView = new Uint8Array(this.currentAssemblyBuffer); combinedView.set(existingView, 0); } // Append new chunk const chunkView = new Uint8Array(chunk.data); combinedView.set(chunkView, this.currentAssemblySize); this.currentAssemblyBuffer = combined; this.currentAssemblySize = newTotalSize; // Check if we should create an assembly chunk this.checkForAssemblyCompletion(); } /** * Check if current assembly should be completed */ checkForAssemblyCompletion() { if (!this.currentAssemblyBuffer) return; const shouldComplete = this.shouldCompleteCurrentAssembly(); if (shouldComplete) { this.completeCurrentAssembly(); } } /** * Determine if current assembly should be completed */ shouldCompleteCurrentAssembly() { // First chunk - optimize for playback start if (this.nextStorageIndex === 0) { // Complete when we reach playback chunk size for instant start return this.currentAssemblySize >= this.options.playbackChunkSize; } // Subsequent chunks - optimize for storage efficiency const reachedStorageSize = this.currentAssemblySize >= this.options.storageChunkSize; const isLastChunk = this.isLastAssemblyChunk(); return reachedStorageSize || isLastChunk; } /** * Check if this is the last assembly chunk */ isLastAssemblyChunk() { // Calculate how much data we've processed let processedBytes = 0; for (const assembledChunk of this.assembledChunks.values()) { processedBytes += assembledChunk.totalSize; } processedBytes += this.currentAssemblySize; return processedBytes >= this.totalExpectedSize; } /** * Complete the current assembly and create an AssemblyChunk */ completeCurrentAssembly() { if (!this.currentAssemblyBuffer || this.currentDownloadChunks.length === 0) { return; } const assemblyStartTime = performance.now(); const assemblyChunk = { id: `assembly-${this.nextStorageIndex}`, storageIndex: this.nextStorageIndex, downloadChunks: [...this.currentDownloadChunks], // Copy array totalSize: this.currentAssemblySize, data: this.currentAssemblyBuffer, assemblyTime: performance.now() - assemblyStartTime, canStartPlayback: this.nextStorageIndex === 0 && !this.isPlaybackReady }; this.assembledChunks.set(this.nextStorageIndex, assemblyChunk); console.log(`[StreamingAssembler] Assembled chunk ${this.nextStorageIndex}: ${(assemblyChunk.totalSize / 1024).toFixed(0)}KB from ${assemblyChunk.downloadChunks.length} download chunks`); // Check if this is the first playback-ready chunk if (assemblyChunk.canStartPlayback) { this.isPlaybackReady = true; console.log(`[StreamingAssembler] 🎵 First chunk ready for playback: ${(assemblyChunk.totalSize / 1024).toFixed(0)}KB`); this.options.onPlaybackReady?.(assemblyChunk); } // Notify listeners this.options.onChunkAssembled?.(assemblyChunk); this.reportProgress(); // Reset assembly state for next chunk this.nextStorageIndex++; this.currentAssemblyBuffer = null; this.currentAssemblySize = 0; this.currentDownloadChunks = []; } /** * Report assembly progress */ reportProgress() { const assembledChunks = this.assembledChunks.size; // Estimate total chunks based on file size and chunk sizes let estimatedTotalChunks = 1; // At least one chunk if (this.totalExpectedSize > 0) { const firstChunkSize = this.options.playbackChunkSize; const remainingSize = Math.max(0, this.totalExpectedSize - firstChunkSize); const remainingChunks = Math.ceil(remainingSize / this.options.storageChunkSize); estimatedTotalChunks = 1 + remainingChunks; } this.options.onProgress?.(assembledChunks, estimatedTotalChunks); } /** * Force completion of any pending assembly */ finalize() { if (this.currentAssemblyBuffer && this.currentDownloadChunks.length > 0) { this.completeCurrentAssembly(); } console.log(`[StreamingAssembler] Finalized: ${this.assembledChunks.size} total chunks assembled`); } /** * Get all assembled chunks in order */ getAssembledChunks() { const chunks = []; for (let i = 0; i < this.nextStorageIndex; i++) { const chunk = this.assembledChunks.get(i); if (chunk) { chunks.push(chunk); } } return chunks; } /** * Get first chunk for immediate playback */ getFirstChunk() { return this.assembledChunks.get(0) || null; } /** * Get assembly statistics */ getStats() { const totalAssembledSize = Array.from(this.assembledChunks.values()) .reduce((sum, chunk) => sum + chunk.totalSize, 0); const assemblyProgress = this.totalExpectedSize > 0 ? (totalAssembledSize + this.currentAssemblySize) / this.totalExpectedSize : 0; return { assembledChunks: this.assembledChunks.size, totalAssembledSize, pendingDownloadChunks: this.downloadChunks.size, isPlaybackReady: this.isPlaybackReady, assemblyProgress: Math.min(assemblyProgress, 1) }; } /** * Clear all data to free memory */ cleanup() { this.downloadChunks.clear(); this.assembledChunks.clear(); this.currentAssemblyBuffer = null; this.currentDownloadChunks = []; this.currentAssemblySize = 0; console.log(`[StreamingAssembler] Cleanup completed`); } } //# sourceMappingURL=StreamingAssembler.js.map