export class AudioService { constructor() { this.mediaSource = null; this.sourceBuffer = null; this.audio = null; this.controller = null; this.eventListeners = new Map(); this.minimumPlaybackSize = 50000; // 50KB minimum before playback this.textLength = 0; this.shouldAutoplay = false; this.CHARS_PER_CHUNK = 150; // Estimated chars per chunk this.serverDownloadPath = null; // Server-side download path this.pendingOperations = []; // Queue for buffer operations } async streamAudio(text, voice, speed, onProgress) { try { console.log('AudioService: Starting stream...', { text, voice, speed }); if (this.controller) { this.controller.abort(); this.controller = null; } this.controller = new AbortController(); this.cleanup(); onProgress?.(0, 1); // Reset progress to 0 this.textLength = text.length; this.shouldAutoplay = document.getElementById('autoplay-toggle').checked; // Calculate expected number of chunks based on text length const estimatedChunks = Math.max(1, Math.ceil(this.textLength / this.CHARS_PER_CHUNK)); console.log('AudioService: Making API call...', { text, voice, speed }); const response = await fetch('/v1/audio/speech', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: text, voice: voice, response_format: 'mp3', // Always use mp3 for streaming playback download_format: document.getElementById('format-select').value || 'mp3', // Format for final download stream: true, speed: speed, return_download_link: true, lang_code: document.getElementById('lang-select').value || undefined }), signal: this.controller.signal }); console.log('AudioService: Got response', { status: response.status, headers: Object.fromEntries(response.headers.entries()) }); // Check for download path as soon as we get the response const downloadPath = response.headers.get('x-download-path'); if (downloadPath) { this.serverDownloadPath = `/v1${downloadPath}`; console.log('Download path received:', this.serverDownloadPath); } if (!response.ok) { const error = await response.json(); console.error('AudioService: API error', error); throw new Error(error.detail?.message || 'Failed to generate speech'); } await this.setupAudioStream(response.body, response, onProgress, estimatedChunks); return this.audio; } catch (error) { this.cleanup(); throw error; } } async setupAudioStream(stream, response, onProgress, estimatedChunks) { this.audio = new Audio(); this.mediaSource = new MediaSource(); this.audio.src = URL.createObjectURL(this.mediaSource); // Monitor for audio element errors this.audio.addEventListener('error', (e) => { console.error('Audio error:', this.audio.error); }); this.audio.addEventListener('ended', () => { this.dispatchEvent('ended'); }); return new Promise((resolve, reject) => { this.mediaSource.addEventListener('sourceopen', async () => { try { this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg'); this.sourceBuffer.mode = 'sequence'; this.sourceBuffer.addEventListener('updateend', () => { this.processNextOperation(); }); await this.processStream(stream, response, onProgress, estimatedChunks); resolve(); } catch (error) { reject(error); } }); }); } async processStream(stream, response, onProgress, estimatedChunks) { const reader = stream.getReader(); let hasStartedPlaying = false; let receivedChunks = 0; try { while (true) { const {value, done} = await reader.read(); if (done) { // Get final download path from header after stream is complete const headers = Object.fromEntries(response.headers.entries()); console.log('Response headers at stream end:', headers); const downloadPath = headers['x-download-path']; if (downloadPath) { // Prepend /v1 since the router is mounted there this.serverDownloadPath = `/v1${downloadPath}`; console.log('Download path received:', this.serverDownloadPath); } else { console.warn('No X-Download-Path header found. Available headers:', Object.keys(headers).join(', ')); } if (this.mediaSource.readyState === 'open') { this.mediaSource.endOfStream(); } // Signal completion onProgress?.(estimatedChunks, estimatedChunks); this.dispatchEvent('complete'); // Check if we should autoplay for small inputs that didn't trigger during streaming if (this.shouldAutoplay && !hasStartedPlaying && this.sourceBuffer.buffered.length > 0) { setTimeout(() => this.play(), 100); } setTimeout(() => { this.dispatchEvent('downloadReady'); }, 800); return; } receivedChunks++; onProgress?.(receivedChunks, estimatedChunks); try { // Check for audio errors before proceeding if (this.audio.error) { console.error('Audio error detected:', this.audio.error); continue; // Skip this chunk if audio is in error state } // Only remove old data if we're hitting quota errors if (this.sourceBuffer.buffered.length > 0) { const currentTime = this.audio.currentTime; const start = this.sourceBuffer.buffered.start(0); const end = this.sourceBuffer.buffered.end(0); // Only remove if we have a lot of historical data if (currentTime - start > 30) { const removeEnd = Math.max(start, currentTime - 15); if (removeEnd > start) { await this.removeBufferRange(start, removeEnd); } } } await this.appendChunk(value); if (!hasStartedPlaying && this.sourceBuffer.buffered.length > 0) { hasStartedPlaying = true; if (this.shouldAutoplay) { setTimeout(() => this.play(), 100); } } } catch (error) { if (error.name === 'QuotaExceededError') { // If we hit quota, try more aggressive cleanup if (this.sourceBuffer.buffered.length > 0) { const currentTime = this.audio.currentTime; const start = this.sourceBuffer.buffered.start(0); const removeEnd = Math.max(start, currentTime - 5); if (removeEnd > start) { await this.removeBufferRange(start, removeEnd); // Retry append after removing data try { await this.appendChunk(value); } catch (retryError) { console.warn('Buffer error after cleanup:', retryError); } } } } else { console.warn('Buffer error:', error); } } } } catch (error) { if (error.name !== 'AbortError') { throw error; } } } async removeBufferRange(start, end) { // Double check that end is greater than start if (end <= start) { console.warn('Invalid buffer remove range:', {start, end}); return; } return new Promise((resolve) => { const doRemove = () => { try { this.sourceBuffer.remove(start, end); } catch (e) { console.warn('Error removing buffer:', e); } resolve(); }; if (this.sourceBuffer.updating) { this.sourceBuffer.addEventListener('updateend', () => { doRemove(); }, { once: true }); } else { doRemove(); } }); } async appendChunk(chunk) { // Don't append if audio is in error state if (this.audio.error) { console.warn('Skipping chunk append due to audio error'); return; } return new Promise((resolve, reject) => { const operation = { chunk, resolve, reject }; this.pendingOperations.push(operation); if (!this.sourceBuffer.updating) { this.processNextOperation(); } }); } processNextOperation() { if (this.sourceBuffer.updating || this.pendingOperations.length === 0) { return; } // Don't process if audio is in error state if (this.audio.error) { console.warn("Skipping operation due to audio error"); return; } const operation = this.pendingOperations.shift(); try { this.sourceBuffer.appendBuffer(operation.chunk); // Set up event listeners const onUpdateEnd = () => { operation.resolve(); this.sourceBuffer.removeEventListener("updateend", onUpdateEnd); this.sourceBuffer.removeEventListener( "updateerror", onUpdateError ); // Process the next operation this.processNextOperation(); }; const onUpdateError = (event) => { operation.reject(event); this.sourceBuffer.removeEventListener("updateend", onUpdateEnd); this.sourceBuffer.removeEventListener( "updateerror", onUpdateError ); // Decide whether to continue processing if (event.name !== "InvalidStateError") { this.processNextOperation(); } }; this.sourceBuffer.addEventListener("updateend", onUpdateEnd); this.sourceBuffer.addEventListener("updateerror", onUpdateError); } catch (error) { operation.reject(error); // Only continue processing if it's not a fatal error if (error.name !== "InvalidStateError") { this.processNextOperation(); } } } play() { if (this.audio && this.audio.readyState >= 2 && !this.audio.error) { const playPromise = this.audio.play(); if (playPromise) { playPromise.catch(error => { if (error.name !== 'AbortError') { console.error('Playback error:', error); } }); } this.dispatchEvent('play'); } } pause() { if (this.audio) { this.audio.pause(); this.dispatchEvent('pause'); } } seek(time) { if (this.audio && !this.audio.error) { const wasPlaying = !this.audio.paused; this.audio.currentTime = time; if (wasPlaying) { this.play(); } } } setVolume(volume) { if (this.audio) { this.audio.volume = Math.max(0, Math.min(1, volume)); } } getCurrentTime() { return this.audio ? this.audio.currentTime : 0; } getDuration() { return this.audio ? this.audio.duration : 0; } isPlaying() { return this.audio ? !this.audio.paused : false; } addEventListener(event, callback) { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set()); } this.eventListeners.get(event).add(callback); if (this.audio && ['play', 'pause', 'ended', 'timeupdate'].includes(event)) { this.audio.addEventListener(event, callback); } } removeEventListener(event, callback) { const listeners = this.eventListeners.get(event); if (listeners) { listeners.delete(callback); } if (this.audio) { this.audio.removeEventListener(event, callback); } } dispatchEvent(event, data) { const listeners = this.eventListeners.get(event); if (listeners) { listeners.forEach(callback => callback(data)); } } cancel() { if (this.controller) { this.controller.abort(); this.controller = null; } if (this.audio) { this.audio.pause(); this.audio.src = ""; this.audio = null; } if (this.mediaSource && this.mediaSource.readyState === "open") { try { this.mediaSource.endOfStream(); } catch (e) { // Ignore errors during cleanup } } this.mediaSource = null; if (this.sourceBuffer) { this.sourceBuffer.removeEventListener("updateend", () => {}); this.sourceBuffer.removeEventListener("updateerror", () => {}); this.sourceBuffer = null; } this.serverDownloadPath = null; this.pendingOperations = []; } cleanup() { if (this.audio) { this.eventListeners.forEach((listeners, event) => { listeners.forEach((callback) => { this.audio.removeEventListener(event, callback); }); }); this.audio.pause(); this.audio.src = ""; this.audio = null; } if (this.mediaSource && this.mediaSource.readyState === "open") { try { this.mediaSource.endOfStream(); } catch (e) { // Ignore errors during cleanup } } this.mediaSource = null; if (this.sourceBuffer) { this.sourceBuffer.removeEventListener("updateend", () => {}); this.sourceBuffer.removeEventListener("updateerror", () => {}); this.sourceBuffer = null; } this.serverDownloadPath = null; this.pendingOperations = []; } getDownloadUrl() { if (!this.serverDownloadPath) { console.warn('No download path available'); return null; } return this.serverDownloadPath; } } export default AudioService;