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
JavaScript
// 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