UNPKG

z-web-audio-stream

Version:

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

356 lines 15 kB
// DownloadManager.ts // Independent network download manager for optimal audio streaming // Separates download strategy from storage chunking for maximum performance /** * Advanced download manager that optimizes network transfers independently from storage * * Key features: * - Network-optimized chunk sizes (64KB-512KB) separate from storage chunks * - Parallel downloads with configurable concurrency * - Adaptive chunk sizing based on connection speed * - Priority downloading for first chunk (instant playback) * - Range request optimization for HTTP/2 performance * - Connection speed detection and adaptation */ export class DownloadManager { strategy; onProgress; onChunkComplete; onComplete; onError; // Download state activeDownloads = new Set(); completedChunks = new Map(); downloadStartTime = 0; totalBytesDownloaded = 0; connectionSpeed = 0; // bytes per second // Connection speed detection speedSamples = []; MAX_SPEED_SAMPLES = 5; // iOS Safari optimizations isIOSSafari; constructor(options = {}) { // Detect iOS Safari for optimizations this.isIOSSafari = this.detectIOSSafari(); // Set default strategy with iOS optimizations this.strategy = { initialChunkSize: this.isIOSSafari ? 128 * 1024 : 256 * 1024, // 128KB iOS, 256KB others standardChunkSize: this.isIOSSafari ? 256 * 1024 : 512 * 1024, // 256KB iOS, 512KB others maxConcurrentDownloads: this.isIOSSafari ? 2 : 4, // Limit concurrent on iOS priorityFirstChunk: true, adaptiveChunkSizing: true, ...options.strategy }; this.onProgress = options.onProgress; this.onChunkComplete = options.onChunkComplete; this.onComplete = options.onComplete; this.onError = options.onError; console.log(`[DownloadManager] Initialized with strategy:`, { ...this.strategy, isIOSSafari: this.isIOSSafari }); } detectIOSSafari() { if (typeof navigator === 'undefined') return false; const userAgent = navigator.userAgent; const isIOS = /iPad|iPhone|iPod/.test(userAgent); const isSafari = /Safari/.test(userAgent) && !/Chrome|CriOS|FxiOS|EdgiOS/.test(userAgent); return isIOS && isSafari; } /** * Check if server supports range requests */ async checkRangeRequestSupport(url) { try { const response = await fetch(url, { method: 'HEAD', headers: { 'Range': 'bytes=0-1' } }); const acceptsRanges = response.headers.get('Accept-Ranges'); const contentRange = response.headers.get('Content-Range'); const status = response.status; // Server supports range requests if: // - Returns 206 Partial Content, or // - Returns Accept-Ranges: bytes header, or // - Returns Content-Range header const supportsRanges = status === 206 || acceptsRanges === 'bytes' || contentRange !== null; console.log(`[DownloadManager] Range request support for ${url}: ${supportsRanges}`); return supportsRanges; } catch (error) { console.warn(`[DownloadManager] Failed to check range support: ${error}`); return false; } } /** * Get optimal download strategy for a file */ async getOptimalStrategy(url, estimatedFileSize) { const supportsRanges = await this.checkRangeRequestSupport(url); if (!supportsRanges) { // No range support - download entire file return { ...this.strategy, initialChunkSize: estimatedFileSize || 10 * 1024 * 1024, // Full file standardChunkSize: estimatedFileSize || 10 * 1024 * 1024, maxConcurrentDownloads: 1, priorityFirstChunk: false, adaptiveChunkSizing: false }; } // Optimize strategy based on estimated file size if (estimatedFileSize) { if (estimatedFileSize < 1024 * 1024) { // Small file < 1MB - download in 2-3 chunks return { ...this.strategy, initialChunkSize: Math.min(this.strategy.initialChunkSize, Math.floor(estimatedFileSize / 3)), standardChunkSize: Math.min(this.strategy.standardChunkSize, Math.floor(estimatedFileSize / 2)), maxConcurrentDownloads: 2 }; } else if (estimatedFileSize > 10 * 1024 * 1024) { // Large file > 10MB - use larger chunks to reduce overhead return { ...this.strategy, standardChunkSize: this.isIOSSafari ? 512 * 1024 : 1024 * 1024, // 512KB iOS, 1MB others maxConcurrentDownloads: this.isIOSSafari ? 3 : 6 }; } } return this.strategy; } /** * Download audio file with optimized chunking strategy */ async downloadAudio(url, options = {}) { console.log(`[DownloadManager] Starting optimized download: ${url}`); // Reset state this.activeDownloads.clear(); this.completedChunks.clear(); this.totalBytesDownloaded = 0; this.speedSamples = []; this.downloadStartTime = performance.now(); // Get optimal strategy for this download const strategy = await this.getOptimalStrategy(url, options.estimatedFileSize); try { // First, get file size via HEAD request const headResponse = await fetch(url, { method: 'HEAD' }); const contentLength = headResponse.headers.get('Content-Length'); const totalSize = contentLength ? parseInt(contentLength, 10) : 0; if (!totalSize) { throw new Error('Unable to determine file size'); } console.log(`[DownloadManager] File size: ${(totalSize / 1024 / 1024).toFixed(2)}MB`); // Calculate download chunks based on strategy const chunks = this.calculateDownloadChunks(totalSize, strategy); // Download with priority handling if (options.priorityFirstChunk && chunks.length > 0) { await this.downloadPriorityFirst(url, chunks, totalSize); } else { await this.downloadParallel(url, chunks, totalSize); } const totalTime = performance.now() - this.downloadStartTime; const averageSpeed = totalSize / (totalTime / 1000); // bytes per second console.log(`[DownloadManager] Download complete: ${totalTime.toFixed(2)}ms, ${(averageSpeed / 1024 / 1024).toFixed(2)}MB/s`); // Sort chunks by index for assembly const sortedChunks = Array.from(this.completedChunks.values()).sort((a, b) => a.index - b.index); this.onComplete?.(totalTime, averageSpeed); return { chunks: sortedChunks, totalSize, downloadTime: totalTime, averageSpeed }; } catch (error) { const downloadError = error; console.error(`[DownloadManager] Download failed: ${downloadError.message}`); this.onError?.(downloadError); throw downloadError; } } /** * Calculate optimal download chunks based on strategy */ calculateDownloadChunks(totalSize, strategy) { const chunks = []; let offset = 0; let chunkIndex = 0; // First chunk (priority chunk for instant playback) if (offset < totalSize) { const chunkSize = Math.min(strategy.initialChunkSize, totalSize - offset); chunks.push({ index: chunkIndex++, start: offset, end: offset + chunkSize - 1 }); offset += chunkSize; } // Subsequent chunks while (offset < totalSize) { let chunkSize = strategy.standardChunkSize; // Adaptive chunk sizing based on connection speed if (strategy.adaptiveChunkSizing && this.connectionSpeed > 0) { // Increase chunk size for faster connections to reduce overhead if (this.connectionSpeed > 5 * 1024 * 1024) { // > 5MB/s chunkSize = Math.min(chunkSize * 2, this.isIOSSafari ? 512 * 1024 : 1024 * 1024); } else if (this.connectionSpeed < 1024 * 1024) { // < 1MB/s chunkSize = Math.max(chunkSize / 2, 64 * 1024); // Min 64KB } } chunkSize = Math.min(chunkSize, totalSize - offset); chunks.push({ index: chunkIndex++, start: offset, end: offset + chunkSize - 1 }); offset += chunkSize; } console.log(`[DownloadManager] Calculated ${chunks.length} download chunks (${strategy.initialChunkSize / 1024}KB first, ${strategy.standardChunkSize / 1024}KB standard)`); return chunks; } /** * Download with priority first chunk for instant playback */ async downloadPriorityFirst(url, chunks, totalSize) { if (chunks.length === 0) return; // Download first chunk immediately at high priority console.log(`[DownloadManager] Priority downloading first chunk (${((chunks[0].end - chunks[0].start + 1) / 1024).toFixed(0)}KB)`); await this.downloadChunk(url, chunks[0]); // Download remaining chunks in parallel if (chunks.length > 1) { const remainingChunks = chunks.slice(1); await this.downloadParallel(url, remainingChunks, totalSize); } } /** * Download chunks in parallel with concurrency control */ async downloadParallel(url, chunks, totalSize) { const maxConcurrent = this.strategy.maxConcurrentDownloads; const promises = []; // Process chunks in batches to control concurrency for (let i = 0; i < chunks.length; i += maxConcurrent) { const batch = chunks.slice(i, i + maxConcurrent); const batchPromises = batch.map(chunk => this.downloadChunk(url, chunk)); // Wait for current batch to complete before starting next await Promise.all(batchPromises); // Update progress this.reportProgress(totalSize); } } /** * Download a single chunk with range request */ async downloadChunk(url, chunkInfo) { const { index, start, end } = chunkInfo; const chunkStartTime = performance.now(); this.activeDownloads.add(index); try { const response = await fetch(url, { headers: { 'Range': `bytes=${start}-${end}` } }); if (!response.ok && response.status !== 206) { throw new Error(`Chunk download failed: ${response.status}`); } const arrayBuffer = await response.arrayBuffer(); const downloadTime = performance.now() - chunkStartTime; // Update connection speed estimation const chunkSize = arrayBuffer.byteLength; const chunkSpeed = chunkSize / (downloadTime / 1000); // bytes per second this.updateConnectionSpeed(chunkSpeed); const chunk = { index, start, end, data: arrayBuffer, downloadTime }; this.completedChunks.set(index, chunk); this.totalBytesDownloaded += chunkSize; this.activeDownloads.delete(index); console.log(`[DownloadManager] Downloaded chunk ${index}: ${(chunkSize / 1024).toFixed(0)}KB in ${downloadTime.toFixed(2)}ms (${(chunkSpeed / 1024 / 1024).toFixed(2)}MB/s)`); this.onChunkComplete?.(chunk); } catch (error) { this.activeDownloads.delete(index); console.error(`[DownloadManager] Failed to download chunk ${index}:`, error); throw error; } } /** * Update connection speed estimation with smoothing */ updateConnectionSpeed(newSpeed) { this.speedSamples.push(newSpeed); if (this.speedSamples.length > this.MAX_SPEED_SAMPLES) { this.speedSamples.shift(); } // Calculate smoothed average speed this.connectionSpeed = this.speedSamples.reduce((sum, speed) => sum + speed, 0) / this.speedSamples.length; } /** * Report download progress */ reportProgress(totalSize) { const chunksCompleted = this.completedChunks.size; const chunksTotal = this.activeDownloads.size + chunksCompleted; const elapsedTime = performance.now() - this.downloadStartTime; let estimatedTimeRemaining = 0; if (this.connectionSpeed > 0 && totalSize > this.totalBytesDownloaded) { const remainingBytes = totalSize - this.totalBytesDownloaded; estimatedTimeRemaining = (remainingBytes / this.connectionSpeed) * 1000; // milliseconds } const progress = { bytesLoaded: this.totalBytesDownloaded, bytesTotal: totalSize, chunksCompleted, chunksTotal, downloadSpeed: this.connectionSpeed, estimatedTimeRemaining }; this.onProgress?.(progress); } /** * Assemble downloaded chunks into a complete ArrayBuffer */ static assembleChunks(chunks) { // Sort chunks by index to ensure correct order const sortedChunks = chunks.sort((a, b) => a.index - b.index); // Calculate total size const totalSize = sortedChunks.reduce((sum, chunk) => sum + chunk.data.byteLength, 0); // Create combined buffer const combined = new ArrayBuffer(totalSize); const combinedView = new Uint8Array(combined); let offset = 0; for (const chunk of sortedChunks) { const chunkView = new Uint8Array(chunk.data); combinedView.set(chunkView, offset); offset += chunk.data.byteLength; } return combined; } /** * Get current download statistics */ getDownloadStats() { return { activeDownloads: this.activeDownloads.size, completedChunks: this.completedChunks.size, connectionSpeed: this.connectionSpeed, totalBytesDownloaded: this.totalBytesDownloaded }; } } //# sourceMappingURL=DownloadManager.js.map