z-web-audio-stream
Version:
iOS Safari-safe Web Audio streaming with separated download/storage optimization, instant playback, and memory management
901 lines • 40.4 kB
JavaScript
// AudioChunkStore.ts
// TypeScript implementation of chunk-based audio storage for iOS Safari compatibility
// Provides progressive loading, security, and offline capabilities with iOS-specific optimizations
/**
* iOS Safari-safe audio chunk storage with progressive loading
*
* Key features:
* - Memory-safe chunk sizing to prevent iOS page reloads
* - Safari-specific IndexedDB retry logic
* - Progressive audio loading for instant playback
* - Automatic cleanup and storage management
* - Simple obfuscation for privacy
*/
export class AudioChunkStore {
db = null;
audioContext;
chunkSizeBytes = 3 * 1024 * 1024; // 3MB per chunk (default)
initialized = false;
dbName = 'WAS_MediaCache_v1';
dbVersion = 2;
// Instant playback chunk configuration
instantChunkConfig = {
initialChunkSize: 384 * 1024, // 384KB for instant playback
subsequentChunkSize: 2 * 1024 * 1024, // 2MB for subsequent chunks
enableInstantMode: true
};
// Simple obfuscation key (not for real security, just to deter casual inspection)
obfuscationKey;
// Storage limits
maxStorageSize = 1024 * 1024 * 1024; // 1GB
maxAge = 10 * 24 * 60 * 60 * 1000; // 10 days
minChunksForPlayback = 1; // Start playback after 1 chunk (3MB loads quickly)
constructor(audioContext, instantConfig, obfuscationKey) {
this.audioContext = audioContext;
this.obfuscationKey = obfuscationKey || 'WebAudioStream2024';
if (instantConfig) {
this.instantChunkConfig = { ...this.instantChunkConfig, ...instantConfig };
}
}
// Simple XOR-based obfuscation for metadata (reversible)
obfuscateString(input) {
let result = '';
for (let i = 0; i < input.length; i++) {
const charCode = input.charCodeAt(i);
const keyChar = this.obfuscationKey.charCodeAt(i % this.obfuscationKey.length);
result += String.fromCharCode(charCode ^ keyChar);
}
// Base64 encode to make it look more scrambled
return btoa(result);
}
deobfuscateString(input) {
try {
// Base64 decode first
const decoded = atob(input);
let result = '';
for (let i = 0; i < decoded.length; i++) {
const charCode = decoded.charCodeAt(i);
const keyChar = this.obfuscationKey.charCodeAt(i % this.obfuscationKey.length);
result += String.fromCharCode(charCode ^ keyChar);
}
return result;
}
catch (error) {
console.warn('[AudioChunkStore] Failed to deobfuscate string:', error);
return input; // Return original if deobfuscation fails
}
}
// Obfuscate sensitive metadata fields
obfuscateMetadata(metadata) {
return {
...metadata,
name: this.obfuscateString(metadata.name),
url: this.obfuscateString(metadata.url),
// Keep technical fields unobfuscated for functionality
trackId: metadata.trackId,
duration: metadata.duration,
sampleRate: metadata.sampleRate,
numberOfChannels: metadata.numberOfChannels,
totalChunks: metadata.totalChunks,
lastAccessed: metadata.lastAccessed,
fileSize: metadata.fileSize,
_obfuscated: true // Flag to indicate this is obfuscated
};
}
// Deobfuscate metadata when reading
deobfuscateMetadata(obfuscatedData) {
if (!obfuscatedData._obfuscated) {
// Already deobfuscated or old format
return obfuscatedData;
}
return {
...obfuscatedData,
name: this.deobfuscateString(obfuscatedData.name),
url: this.deobfuscateString(obfuscatedData.url)
};
}
// Safari iOS detection
isSafariIOS() {
if (typeof navigator === 'undefined')
return false;
return /iPad|iPhone|iPod/.test(navigator.userAgent) && /Safari/.test(navigator.userAgent);
}
// Promise timeout utility for Safari IndexedDB operations
withTimeout(promise, timeoutMs = 5000) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Safari IndexedDB operation timeout')), timeoutMs))
]);
}
// Safari-safe IndexedDB database opening with retry logic
openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
this.initialized = true;
console.log('[AudioChunkStore] Database initialized');
// Run cleanup on startup
this.cleanup().catch(console.warn);
resolve();
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object stores with obfuscated names
if (!db.objectStoreNames.contains('cache_meta')) {
const metadataStore = db.createObjectStore('cache_meta', { keyPath: 'trackId' });
metadataStore.createIndex('lastAccessed', 'lastAccessed');
metadataStore.createIndex('url', 'url');
}
if (!db.objectStoreNames.contains('cache_data')) {
const chunksStore = db.createObjectStore('cache_data', { keyPath: 'id' });
chunksStore.createIndex('trackId', 'trackId');
chunksStore.createIndex('chunkIndex', 'chunkIndex');
}
console.log('[AudioChunkStore] Database schema created');
};
});
}
async initialize() {
if (this.initialized)
return;
// Apply Safari iOS workarounds
if (this.isSafariIOS()) {
console.log('[AudioChunkStore] Safari iOS detected, applying workarounds');
// Reduce chunk size for Safari iOS memory constraints
this.chunkSizeBytes = 1 * 1024 * 1024; // 1MB instead of 3MB for Safari
}
// Safari iOS fix: IndexedDB fails 100% of the time on first try since iOS 14.6
const maxRetries = this.isSafariIOS() ? 3 : 1;
let retryCount = 0;
while (retryCount < maxRetries) {
try {
console.log(`[AudioChunkStore] Attempting to open IndexedDB (attempt ${retryCount + 1}/${maxRetries})`);
// Add timeout protection for Safari
await this.withTimeout(this.openDatabase(), 10000);
console.log('[AudioChunkStore] IndexedDB opened successfully');
return;
}
catch (error) {
retryCount++;
console.warn(`[AudioChunkStore] IndexedDB open attempt ${retryCount} failed:`, error);
if (retryCount >= maxRetries) {
console.error('[AudioChunkStore] All IndexedDB connection attempts failed');
throw new Error(`Safari IndexedDB failed after ${maxRetries} retries: ${error}`);
}
// Wait between retries (longer for Safari iOS)
const retryDelay = this.isSafariIOS() ? 1000 : 500;
console.log(`[AudioChunkStore] Waiting ${retryDelay}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}
// Store audio file in chunks with progressive loading
async storeAudio(url, trackId, name, progressCallback) {
if (!this.initialized)
await this.initialize();
// Check if already stored
const existingMetadata = await this.getMetadata(trackId);
if (existingMetadata) {
// Update last accessed time
await this.updateLastAccessed(trackId);
return existingMetadata;
}
console.log(`[AudioChunkStore] Storing audio: ${name} (${trackId})`);
// Fetch and decode audio
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch audio: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
// Create metadata - calculate chunks based on size, not time
const bytesPerSample = 4; // 32-bit float
const totalBytes = audioBuffer.length * audioBuffer.numberOfChannels * bytesPerSample;
const totalChunks = Math.ceil(totalBytes / this.chunkSizeBytes);
const metadata = {
trackId,
name,
duration: audioBuffer.duration,
sampleRate: audioBuffer.sampleRate,
numberOfChannels: audioBuffer.numberOfChannels,
totalChunks,
lastAccessed: Date.now(),
fileSize: arrayBuffer.byteLength,
url
};
// Store metadata first
await this.saveMetadata(metadata);
// Convert to chunks and store progressively
const chunks = this.audioBufferToChunks(audioBuffer, trackId);
for (let i = 0; i < chunks.length; i++) {
await this.saveChunk(chunks[i]);
// Report progress
if (progressCallback) {
const loaded = i + 1;
const canStartPlayback = loaded >= this.minChunksForPlayback;
progressCallback(loaded, totalChunks, canStartPlayback);
}
}
console.log(`[AudioChunkStore] Stored ${chunks.length} chunks for ${name}`);
return metadata;
}
// Get audio buffer for playback (can be partial)
async getAudioBuffer(trackId, startChunk = 0, chunkCount) {
if (!this.initialized)
await this.initialize();
const metadata = await this.getMetadata(trackId);
if (!metadata)
return null;
// Update last accessed
await this.updateLastAccessed(trackId);
// Determine chunks to load
const endChunk = chunkCount
? Math.min(startChunk + chunkCount, metadata.totalChunks)
: metadata.totalChunks;
// Load chunks
const chunks = [];
for (let i = startChunk; i < endChunk; i++) {
const chunk = await this.getChunk(trackId, i);
if (chunk)
chunks.push(chunk);
}
if (chunks.length === 0)
return null;
// Merge chunks into AudioBuffer
return this.mergeChunks(chunks, metadata);
}
// Check if track is stored (any chunks)
async isStored(trackId) {
if (!this.initialized)
await this.initialize();
const metadata = await this.getMetadata(trackId);
return !!metadata;
}
// Get available chunks for a track
async getAvailableChunks(trackId) {
if (!this.initialized)
await this.initialize();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['cache_data'], 'readonly');
const store = transaction.objectStore('cache_data');
const index = store.index('trackId');
const request = index.getAll(trackId);
request.onsuccess = () => {
const chunks = request.result;
const availableChunks = chunks.map(chunk => chunk.chunkIndex).sort((a, b) => a - b);
resolve(availableChunks);
};
request.onerror = () => reject(request.error);
});
}
audioBufferToChunks(audioBuffer, trackId) {
const bytesPerSample = 4; // 32-bit float
const samplesPerChunk = Math.floor(this.chunkSizeBytes / (audioBuffer.numberOfChannels * bytesPerSample));
const totalSamples = audioBuffer.length;
const chunks = [];
for (let offset = 0; offset < totalSamples; offset += samplesPerChunk) {
const length = Math.min(samplesPerChunk, totalSamples - offset);
const chunkIndex = Math.floor(offset / samplesPerChunk);
const channels = [];
for (let c = 0; c < audioBuffer.numberOfChannels; c++) {
const channelData = audioBuffer.getChannelData(c);
const chunkData = new Float32Array(length);
for (let i = 0; i < length; i++) {
chunkData[i] = channelData[offset + i];
}
channels.push(chunkData);
}
chunks.push({
id: `${trackId}-${chunkIndex}`,
trackId,
chunkIndex,
sampleRate: audioBuffer.sampleRate,
length,
channels
});
}
console.log(`[AudioChunkStore] Created ${chunks.length} size-based chunks (${Math.round(this.chunkSizeBytes / 1024 / 1024)}MB each) for ${trackId}`);
return chunks;
}
mergeChunks(chunks, metadata) {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const audioBuffer = this.audioContext.createBuffer(metadata.numberOfChannels, totalLength, metadata.sampleRate);
let offset = 0;
for (const chunk of chunks) {
for (let c = 0; c < metadata.numberOfChannels; c++) {
const targetChannel = audioBuffer.getChannelData(c);
const sourceChannel = chunk.channels[c];
targetChannel.set(sourceChannel, offset);
}
offset += chunk.length;
}
return audioBuffer;
}
float32ArrayToString(f32) {
const u16 = new Uint16Array(f32.buffer, f32.byteOffset, f32.byteLength / 2);
if ('TextDecoder' in window) {
const decoder = new TextDecoder('utf-16');
return decoder.decode(u16);
}
let str = '';
for (let i = 0; i < u16.length; i += 10000) {
const end = Math.min(i + 10000, u16.length);
str += String.fromCharCode.apply(null, Array.from(u16.subarray(i, end)));
}
return str;
}
stringToFloat32Array(str) {
const u16 = new Uint16Array(str.length);
for (let i = 0; i < str.length; i++) {
u16[i] = str.charCodeAt(i);
}
return new Float32Array(u16.buffer);
}
// Database operations
async saveMetadata(metadata) {
const obfuscatedMetadata = this.obfuscateMetadata(metadata);
const operation = new Promise((resolve, reject) => {
const transaction = this.db.transaction(['cache_meta'], 'readwrite');
const store = transaction.objectStore('cache_meta');
const request = store.put(obfuscatedMetadata);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
// Add timeout protection for Safari
return this.withTimeout(operation, 5000);
}
async getMetadata(trackId) {
const operation = new Promise((resolve, reject) => {
const transaction = this.db.transaction(['cache_meta'], 'readonly');
const store = transaction.objectStore('cache_meta');
const request = store.get(trackId);
request.onsuccess = () => {
const result = request.result;
if (result) {
const deobfuscated = this.deobfuscateMetadata(result);
resolve(deobfuscated);
}
else {
resolve(null);
}
};
request.onerror = () => reject(request.error);
});
// Add timeout protection for Safari
return this.withTimeout(operation, 5000);
}
async updateLastAccessed(trackId) {
const metadata = await this.getMetadata(trackId);
if (metadata) {
metadata.lastAccessed = Date.now();
await this.saveMetadata(metadata);
}
}
async saveChunk(chunk) {
const storedChunk = {
...chunk,
channels: chunk.channels.map(channel => this.float32ArrayToString(channel))
};
const operation = new Promise((resolve, reject) => {
const transaction = this.db.transaction(['cache_data'], 'readwrite');
const store = transaction.objectStore('cache_data');
const request = store.put(storedChunk);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
// Add timeout protection for Safari
return this.withTimeout(operation, 5000);
}
async getChunk(trackId, chunkIndex) {
const id = `${trackId}-${chunkIndex}`;
const operation = new Promise((resolve, reject) => {
const transaction = this.db.transaction(['cache_data'], 'readonly');
const store = transaction.objectStore('cache_data');
const request = store.get(id);
request.onsuccess = () => {
const storedChunk = request.result;
if (!storedChunk) {
resolve(null);
return;
}
const chunk = {
...storedChunk,
channels: storedChunk.channels.map(channelStr => this.stringToFloat32Array(channelStr))
};
resolve(chunk);
};
request.onerror = () => reject(request.error);
});
// Add timeout protection for Safari
return this.withTimeout(operation, 5000);
}
// Cleanup operations
async cleanup() {
if (!this.initialized)
await this.initialize();
console.log('[AudioChunkStore] Running cleanup...');
// Get all metadata
const allMetadata = await this.getAllMetadata();
const now = Date.now();
let totalSize = 0;
// Calculate total storage size
for (const metadata of allMetadata) {
totalSize += metadata.fileSize;
}
// Remove old tracks
const tracksToRemove = [];
for (const metadata of allMetadata) {
const age = now - metadata.lastAccessed;
if (age > this.maxAge) {
tracksToRemove.push(metadata.trackId);
totalSize -= metadata.fileSize;
}
}
// Remove tracks if over size limit (oldest first)
if (totalSize > this.maxStorageSize) {
const sortedByAge = allMetadata
.filter(m => !tracksToRemove.includes(m.trackId))
.sort((a, b) => a.lastAccessed - b.lastAccessed);
for (const metadata of sortedByAge) {
if (totalSize <= this.maxStorageSize)
break;
tracksToRemove.push(metadata.trackId);
totalSize -= metadata.fileSize;
}
}
// Remove selected tracks
for (const trackId of tracksToRemove) {
await this.removeTrack(trackId);
}
if (tracksToRemove.length > 0) {
console.log(`[AudioChunkStore] Cleaned up ${tracksToRemove.length} tracks`);
}
}
async getAllMetadata() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['cache_meta'], 'readonly');
const store = transaction.objectStore('cache_meta');
const request = store.getAll();
request.onsuccess = () => {
const results = request.result.map((item) => this.deobfuscateMetadata(item));
resolve(results);
};
request.onerror = () => reject(request.error);
});
}
async removeTrack(trackId) {
if (!this.initialized)
await this.initialize();
// Remove metadata
await new Promise((resolve, reject) => {
const transaction = this.db.transaction(['cache_meta'], 'readwrite');
const store = transaction.objectStore('cache_meta');
const request = store.delete(trackId);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
// Remove all chunks for this track
await new Promise((resolve, reject) => {
const transaction = this.db.transaction(['cache_data'], 'readwrite');
const store = transaction.objectStore('cache_data');
const index = store.index('trackId');
const request = index.openKeyCursor(IDBKeyRange.only(trackId));
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
store.delete(cursor.primaryKey);
cursor.continue();
}
else {
resolve();
}
};
request.onerror = () => reject(request.error);
});
console.log(`[AudioChunkStore] Removed track: ${trackId}`);
}
// Store audio with instant playback support (variable chunk sizes)
async storeAudioForInstantPlayback(url, trackId, name, progressCallback) {
if (!this.initialized)
await this.initialize();
// Check if already stored
const existingMetadata = await this.getMetadata(trackId);
if (existingMetadata) {
await this.updateLastAccessed(trackId);
return existingMetadata;
}
console.log(`[AudioChunkStore] Storing audio for instant playback: ${name} (${trackId})`);
// Fetch and decode audio
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch audio: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
// Create chunks with variable sizes for instant playback
const chunks = this.audioBufferToInstantChunks(audioBuffer, trackId);
const metadata = {
trackId,
name,
duration: audioBuffer.duration,
sampleRate: audioBuffer.sampleRate,
numberOfChannels: audioBuffer.numberOfChannels,
totalChunks: chunks.length,
lastAccessed: Date.now(),
fileSize: arrayBuffer.byteLength,
url
};
// Store metadata first
await this.saveMetadata(metadata);
// Store chunks progressively - first chunk has priority
for (let i = 0; i < chunks.length; i++) {
await this.saveChunk(chunks[i]);
// Report progress
if (progressCallback) {
const loaded = i + 1;
const canStartPlayback = loaded >= 1; // Can start after first chunk
progressCallback(loaded, chunks.length, canStartPlayback);
}
}
console.log(`[AudioChunkStore] Stored ${chunks.length} variable-size chunks for instant playback: ${name}`);
return metadata;
}
// Convert audio buffer to variable-sized chunks for instant playback
audioBufferToInstantChunks(audioBuffer, trackId) {
const chunks = [];
const bytesPerSample = 4; // 32-bit float
const totalSamples = audioBuffer.length;
// Calculate chunk sizes in samples
const initialChunkSamples = Math.floor(this.instantChunkConfig.initialChunkSize / (audioBuffer.numberOfChannels * bytesPerSample));
const subsequentChunkSamples = Math.floor(this.instantChunkConfig.subsequentChunkSize / (audioBuffer.numberOfChannels * bytesPerSample));
let offset = 0;
let chunkIndex = 0;
while (offset < totalSamples) {
// Use smaller chunk size for first chunk, larger for subsequent
const chunkSamples = chunkIndex === 0 ? initialChunkSamples : subsequentChunkSamples;
const length = Math.min(chunkSamples, totalSamples - offset);
const channels = [];
for (let c = 0; c < audioBuffer.numberOfChannels; c++) {
const channelData = audioBuffer.getChannelData(c);
const chunkData = new Float32Array(length);
for (let i = 0; i < length; i++) {
chunkData[i] = channelData[offset + i];
}
channels.push(chunkData);
}
chunks.push({
id: `${trackId}-${chunkIndex}`,
trackId,
chunkIndex,
sampleRate: audioBuffer.sampleRate,
length,
channels
});
offset += length;
chunkIndex++;
}
const initialSizeKB = Math.round(this.instantChunkConfig.initialChunkSize / 1024);
const subsequentSizeKB = Math.round(this.instantChunkConfig.subsequentChunkSize / 1024);
console.log(`[AudioChunkStore] Created ${chunks.length} instant chunks for ${trackId}: first chunk ${initialSizeKB}KB, subsequent ${subsequentSizeKB}KB`);
return chunks;
}
// Load first chunk only for instant playback
async getFirstChunk(trackId) {
if (!this.initialized)
await this.initialize();
const metadata = await this.getMetadata(trackId);
if (!metadata)
return null;
// Load only the first chunk
const firstChunk = await this.getChunk(trackId, 0);
if (!firstChunk)
return null;
// Create buffer from first chunk only
const audioBuffer = this.audioContext.createBuffer(metadata.numberOfChannels, firstChunk.length, metadata.sampleRate);
for (let c = 0; c < metadata.numberOfChannels; c++) {
const targetChannel = audioBuffer.getChannelData(c);
const sourceChannel = firstChunk.channels[c];
targetChannel.set(sourceChannel, 0);
}
await this.updateLastAccessed(trackId);
return audioBuffer;
}
// Get progressive chunks for seamless replacement
async getProgressiveChunks(trackId, startChunk = 0, maxChunks) {
if (!this.initialized)
await this.initialize();
const metadata = await this.getMetadata(trackId);
if (!metadata)
return null;
const endChunk = maxChunks
? Math.min(startChunk + maxChunks, metadata.totalChunks)
: metadata.totalChunks;
// Load chunks
const chunks = [];
for (let i = startChunk; i < endChunk; i++) {
const chunk = await this.getChunk(trackId, i);
if (chunk)
chunks.push(chunk);
}
if (chunks.length === 0)
return null;
// Merge chunks into single AudioBuffer
return this.mergeChunks(chunks, metadata);
}
// Configure instant playback settings
configureInstantMode(config) {
this.instantChunkConfig = { ...this.instantChunkConfig, ...config };
// Update iOS Safari optimization if needed
if (this.isSafariIOS()) {
this.instantChunkConfig.initialChunkSize = Math.min(this.instantChunkConfig.initialChunkSize, 256 * 1024 // Cap at 256KB for iOS Safari
);
this.instantChunkConfig.subsequentChunkSize = Math.min(this.instantChunkConfig.subsequentChunkSize, 1024 * 1024 // Cap at 1MB for iOS Safari
);
}
console.log(`[AudioChunkStore] Instant mode configured: initial=${Math.round(this.instantChunkConfig.initialChunkSize / 1024)}KB, subsequent=${Math.round(this.instantChunkConfig.subsequentChunkSize / 1024)}KB`);
}
// Store audio with streaming support for instant playback
async storeAudioStreaming(url, trackId, name, options = {}) {
if (!this.initialized)
await this.initialize();
// Check if already stored
const existingMetadata = await this.getMetadata(trackId);
if (existingMetadata) {
await this.updateLastAccessed(trackId);
return existingMetadata;
}
console.log(`[AudioChunkStore] Storing audio with streaming: ${name} (${trackId})`);
const initialChunkSize = options.initialChunkSize || this.instantChunkConfig.initialChunkSize;
const subsequentChunkSize = options.subsequentChunkSize || this.instantChunkConfig.subsequentChunkSize;
const useRangeRequests = options.useRangeRequests !== false; // Default to true
try {
// Try streaming approach first
if (useRangeRequests && await this.checkRangeSupport(url)) {
return await this.storeAudioWithRangeRequests(url, trackId, name, initialChunkSize, subsequentChunkSize, options.progressCallback);
}
else {
// Fallback to standard progressive loading
console.log(`[AudioChunkStore] Range requests not supported, using progressive loading`);
return await this.storeAudioForInstantPlayback(url, trackId, name, options.progressCallback);
}
}
catch (error) {
console.warn(`[AudioChunkStore] Streaming storage failed, falling back to standard method: ${error}`);
return await this.storeAudio(url, trackId, name, options.progressCallback);
}
}
// Check if server supports Range requests
async checkRangeSupport(url) {
try {
const response = await fetch(url, { method: 'HEAD' });
const acceptRanges = response.headers.get('accept-ranges');
const contentLength = response.headers.get('content-length');
return acceptRanges === 'bytes' && contentLength !== null;
}
catch (error) {
console.warn(`[AudioChunkStore] Range support check failed: ${error}`);
return false;
}
}
// Store audio using Range requests for better streaming
async storeAudioWithRangeRequests(url, trackId, name, initialChunkSize, subsequentChunkSize, progressCallback) {
// Get file size first
const fileSize = await this.getFileSize(url);
if (!fileSize) {
throw new Error('Could not determine file size');
}
console.log(`[AudioChunkStore] Using Range requests for ${name} (${(fileSize / 1024 / 1024).toFixed(1)}MB)`);
// Load initial chunk
const initialResponse = await fetch(url, {
headers: {
'Range': `bytes=0-${initialChunkSize - 1}`
}
});
if (!initialResponse.ok && initialResponse.status !== 206) {
throw new Error(`Failed to fetch initial chunk: ${initialResponse.status}`);
}
const initialBuffer = await initialResponse.arrayBuffer();
const initialAudioBuffer = await this.audioContext.decodeAudioData(initialBuffer);
// Create preliminary metadata
const totalChunks = Math.ceil(fileSize / subsequentChunkSize);
const metadata = {
trackId,
name,
duration: initialAudioBuffer.duration,
sampleRate: initialAudioBuffer.sampleRate,
numberOfChannels: initialAudioBuffer.numberOfChannels,
totalChunks,
lastAccessed: Date.now(),
fileSize,
url
};
// Store metadata first
await this.saveMetadata(metadata);
// Store initial chunk
const initialChunk = this.audioBufferToSingleChunk(initialAudioBuffer, trackId, 0);
await this.saveChunk(initialChunk);
// Report initial progress
progressCallback?.(1, totalChunks, true); // Can start playback
// Load remaining chunks in background
this.loadRemainingChunksInBackground(url, trackId, initialChunkSize, subsequentChunkSize, fileSize, progressCallback);
return metadata;
}
// Get file size using HEAD request
async getFileSize(url) {
try {
const response = await fetch(url, { method: 'HEAD' });
const contentLength = response.headers.get('content-length');
return contentLength ? parseInt(contentLength, 10) : 0;
}
catch (error) {
console.warn(`[AudioChunkStore] Failed to get file size: ${error}`);
return 0;
}
}
// Convert single AudioBuffer to chunk
audioBufferToSingleChunk(audioBuffer, trackId, chunkIndex) {
const channels = [];
for (let c = 0; c < audioBuffer.numberOfChannels; c++) {
channels.push(audioBuffer.getChannelData(c));
}
return {
id: `${trackId}-${chunkIndex}`,
trackId,
chunkIndex,
sampleRate: audioBuffer.sampleRate,
length: audioBuffer.length,
channels
};
}
// Load remaining chunks in background
async loadRemainingChunksInBackground(url, trackId, initialChunkSize, subsequentChunkSize, fileSize, progressCallback) {
let currentOffset = initialChunkSize;
let chunkIndex = 1;
const totalChunks = Math.ceil(fileSize / subsequentChunkSize);
try {
while (currentOffset < fileSize) {
const endOffset = Math.min(currentOffset + subsequentChunkSize - 1, fileSize - 1);
console.log(`[AudioChunkStore] Loading background chunk ${chunkIndex} (${(currentOffset / 1024).toFixed(1)}KB - ${(endOffset / 1024).toFixed(1)}KB)`);
const response = await fetch(url, {
headers: {
'Range': `bytes=${currentOffset}-${endOffset}`
}
});
if (!response.ok && response.status !== 206) {
console.warn(`[AudioChunkStore] Failed to fetch background chunk ${chunkIndex}: ${response.status}`);
break;
}
const chunkBuffer = await response.arrayBuffer();
// For background chunks, we might store them as raw data for later processing
// This is a simplified approach - in production, you might want to decode incrementally
progressCallback?.(chunkIndex + 1, totalChunks, true);
currentOffset = endOffset + 1;
chunkIndex++;
// Add small delay to prevent overwhelming the browser
await new Promise(resolve => setTimeout(resolve, 50));
}
console.log(`[AudioChunkStore] Completed background loading for ${trackId}`);
}
catch (error) {
console.warn(`[AudioChunkStore] Background loading failed: ${error}`);
}
}
/**
* Store assembly chunks from streaming assembler
*/
async storeAssemblyChunks(trackId, name, assemblyChunks, progressCallback) {
if (!this.initialized)
await this.initialize();
console.log(`[AudioChunkStore] Storing ${assemblyChunks.length} assembly chunks for: ${name} (${trackId})`);
// Calculate total metadata from assembly chunks
const totalSize = assemblyChunks.reduce((sum, chunk) => sum + chunk.totalSize, 0);
// Use first chunk to get audio metadata (decode small portion for properties)
const firstChunk = assemblyChunks[0];
if (!firstChunk) {
throw new Error('No assembly chunks provided');
}
// Decode first chunk to get audio properties
const firstBuffer = await this.audioContext.decodeAudioData(firstChunk.data.slice(0));
const metadata = {
trackId,
name,
duration: 0, // Will be calculated from all chunks
sampleRate: firstBuffer.sampleRate,
numberOfChannels: firstBuffer.numberOfChannels,
totalChunks: assemblyChunks.length,
lastAccessed: Date.now(),
fileSize: totalSize,
url: `assembly://${trackId}` // Special URL to indicate assembly origin
};
// Store metadata first
await this.saveMetadata(metadata);
// Convert assembly chunks to storage chunks
let totalDuration = 0;
for (let i = 0; i < assemblyChunks.length; i++) {
const assemblyChunk = assemblyChunks[i];
// Decode the chunk to get proper audio data
const audioBuffer = await this.audioContext.decodeAudioData(assemblyChunk.data.slice(0));
totalDuration += audioBuffer.duration;
// Convert to storage chunk format
const storageChunk = this.audioBufferToStorageChunk(audioBuffer, trackId, i);
await this.saveChunk(storageChunk);
// Report progress
if (progressCallback) {
const canStartPlayback = i === 0; // First chunk enables playback
progressCallback(i + 1, assemblyChunks.length, canStartPlayback);
}
}
// Update metadata with correct duration
metadata.duration = totalDuration;
await this.saveMetadata(metadata);
console.log(`[AudioChunkStore] Stored ${assemblyChunks.length} assembly chunks for ${name} (total duration: ${totalDuration.toFixed(2)}s)`);
return metadata;
}
/**
* Convert an AudioBuffer to a storage chunk
*/
audioBufferToStorageChunk(audioBuffer, trackId, chunkIndex) {
const channels = [];
for (let c = 0; c < audioBuffer.numberOfChannels; c++) {
channels.push(audioBuffer.getChannelData(c));
}
return {
id: `${trackId}-${chunkIndex}`,
trackId,
chunkIndex,
sampleRate: audioBuffer.sampleRate,
length: audioBuffer.length,
channels
};
}
/**
* Get first chunk for instant playback (optimized for assembly chunks)
*/
async getFirstChunkBuffer(trackId) {
if (!this.initialized)
await this.initialize();
const firstChunk = await this.getChunk(trackId, 0);
if (!firstChunk)
return null;
// Create AudioBuffer from first chunk
const audioBuffer = this.audioContext.createBuffer(firstChunk.channels.length, firstChunk.length, firstChunk.sampleRate);
for (let c = 0; c < firstChunk.channels.length; c++) {
const channelData = audioBuffer.getChannelData(c);
channelData.set(firstChunk.channels[c]);
}
return audioBuffer;
}
// Get storage statistics
async getStorageInfo() {
const allMetadata = await this.getAllMetadata();
return {
totalTracks: allMetadata.length,
totalSize: allMetadata.reduce((sum, m) => sum + m.fileSize, 0),
oldestTrack: allMetadata.length > 0 ? Math.min(...allMetadata.map(m => m.lastAccessed)) : 0,
newestTrack: allMetadata.length > 0 ? Math.max(...allMetadata.map(m => m.lastAccessed)) : 0,
instantModeEnabled: this.instantChunkConfig.enableInstantMode,
chunkSizeConfig: {
initialChunkSize: this.instantChunkConfig.initialChunkSize,
subsequentChunkSize: this.instantChunkConfig.subsequentChunkSize
}
};
}
// Get performance metrics for instant playback
getInstantPlaybackMetrics() {
const initialSizeKB = this.instantChunkConfig.initialChunkSize / 1024;
const subsequentSizeKB = this.instantChunkConfig.subsequentChunkSize / 1024;
// Estimate load time based on typical connection speeds
// Using conservative 3G speeds as baseline (1.5 Mbps = ~200 KB/s)
const estimatedInitialLoadTime = (initialSizeKB / 200) * 1000; // ms
const optimalForInstantPlayback = estimatedInitialLoadTime <= 500; // Under 500ms
return {
initialChunkSize: this.instantChunkConfig.initialChunkSize,
subsequentChunkSize: this.instantChunkConfig.subsequentChunkSize,
estimatedInitialLoadTime,
optimalForInstantPlayback
};
}
}
//# sourceMappingURL=AudioChunkStore.js.map