Refactor web player architecture: separate concerns into PlayerState, VoiceService, and AudioService; update HTML and CSS for new structure; add progress bar styles and ignore unnecessary files.

This commit is contained in:
remsky 2025-01-28 03:25:29 -07:00
parent 75889e157d
commit 18b15728a8
12 changed files with 1106 additions and 450 deletions

1
.gitignore vendored
View file

@ -66,3 +66,4 @@ examples/ebook_test/chapters_to_audio.py
examples/ebook_test/parse_epub.py
examples/ebook_test/River_of_Teet_-_Sarah_Gailey.epub
examples/ebook_test/River_of_Teet_-_Sarah_Gailey.txt
api/src/voices/af_jadzia.pt

View file

@ -34,6 +34,7 @@ class StreamingAudioWriter:
# For MP3, we'll use pydub's incremental writer
self.buffer = BytesIO()
self.segments = [] # Store segments until we have enough data
self.total_duration = 0 # Track total duration in milliseconds
# Initialize an empty AudioSegment as our encoder
self.encoder = AudioSegment.silent(duration=0, frame_rate=self.sample_rate)
@ -85,7 +86,17 @@ class StreamingAudioWriter:
elif self.format == "mp3":
# Final export of any remaining audio
if hasattr(self, 'encoder') and len(self.encoder) > 0:
self.encoder.export(buffer, format="mp3", bitrate="192k", parameters=["-q:a", "2"])
# Export with duration metadata
self.encoder.export(
buffer,
format="mp3",
bitrate="192k",
parameters=[
"-q:a", "2",
"-write_xing", "1", # Force XING/LAME header
"-metadata", f"duration={self.total_duration/1000}" # Duration in seconds
]
)
self.encoder = None
return buffer.getvalue()
@ -119,11 +130,18 @@ class StreamingAudioWriter:
channels=self.channels
)
# Track total duration
self.total_duration += len(segment)
# Add segment to encoder
self.encoder = self.encoder + segment
# Export current state to buffer
self.encoder.export(buffer, format="mp3", bitrate="192k", parameters=["-q:a", "2"])
self.encoder.export(buffer, format="mp3", bitrate="192k", parameters=[
"-q:a", "2",
"-write_xing", "1", # Force XING/LAME header
"-metadata", f"duration={self.total_duration/1000}" # Duration in seconds
])
# Get the encoded data
encoded_data = buffer.getvalue()

View file

@ -1,445 +0,0 @@
class KokoroPlayer {
constructor() {
this.elements = {
textInput: document.getElementById('text-input'),
voiceSearch: document.getElementById('voice-search'),
voiceDropdown: document.getElementById('voice-dropdown'),
voiceOptions: document.getElementById('voice-options'),
selectedVoices: document.getElementById('selected-voices'),
autoplayToggle: document.getElementById('autoplay-toggle'),
formatSelect: document.getElementById('format-select'),
generateBtn: document.getElementById('generate-btn'),
cancelBtn: document.getElementById('cancel-btn'),
playPauseBtn: document.getElementById('play-pause-btn'),
waveContainer: document.getElementById('wave-container'),
timeDisplay: document.getElementById('time-display'),
downloadBtn: document.getElementById('download-btn'),
status: document.getElementById('status'),
speedSlider: document.getElementById('speed-slider'),
speedValue: document.getElementById('speed-value')
};
this.isGenerating = false;
this.availableVoices = [];
this.selectedVoiceSet = new Set();
this.currentController = null;
this.audioChunks = [];
this.sound = null;
this.wave = null;
this.init();
}
async init() {
await this.loadVoices();
this.setupWave();
this.setupEventListeners();
this.setupAudioControls();
}
setupWave() {
this.wave = new SiriWave({
container: this.elements.waveContainer,
width: this.elements.waveContainer.clientWidth,
height: 80,
style: 'ios9',
// color: '#6366f1',
speed: 0.02,
amplitude: 0.7,
frequency: 4
});
}
formatTime(secs) {
const minutes = Math.floor(secs / 60);
const seconds = Math.floor(secs % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
updateTimeDisplay() {
if (!this.sound) return;
const seek = this.sound.seek() || 0;
const duration = this.sound.duration() || 0;
this.elements.timeDisplay.textContent = `${this.formatTime(seek)} / ${this.formatTime(duration)}`;
// Update seek slider
const seekSlider = document.getElementById('seek-slider');
seekSlider.value = (seek / duration) * 100 || 0;
if (this.sound.playing()) {
requestAnimationFrame(() => this.updateTimeDisplay());
}
}
setupAudioControls() {
const seekSlider = document.getElementById('seek-slider');
const volumeSlider = document.getElementById('volume-slider');
seekSlider.addEventListener('input', (e) => {
if (!this.sound) return;
const duration = this.sound.duration();
const seekTime = (duration * e.target.value) / 100;
this.sound.seek(seekTime);
});
volumeSlider.addEventListener('input', (e) => {
if (!this.sound) return;
const volume = e.target.value / 100;
this.sound.volume(volume);
});
}
async loadVoices() {
try {
const response = await fetch('/v1/audio/voices');
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail?.message || 'Failed to load voices');
}
const data = await response.json();
if (!data.voices?.length) {
throw new Error('No voices available');
}
this.availableVoices = data.voices;
this.renderVoiceOptions(this.availableVoices);
if (this.selectedVoiceSet.size === 0) {
const firstVoice = this.availableVoices.find(voice => voice && voice.trim());
if (firstVoice) {
this.addSelectedVoice(firstVoice);
}
}
this.showStatus('Voices loaded successfully', 'success');
} catch (error) {
this.showStatus('Failed to load voices: ' + error.message, 'error');
this.elements.generateBtn.disabled = true;
}
}
renderVoiceOptions(voices) {
this.elements.voiceOptions.innerHTML = voices
.map(voice => `
<label class="voice-option">
<input type="checkbox" value="${voice}"
${this.selectedVoiceSet.has(voice) ? 'checked' : ''}>
${voice}
</label>
`)
.join('');
this.updateSelectedVoicesDisplay();
}
updateSelectedVoicesDisplay() {
this.elements.selectedVoices.innerHTML = Array.from(this.selectedVoiceSet)
.map(voice => `
<span class="selected-voice-tag">
${voice}
<span class="remove-voice" data-voice="${voice}">×</span>
</span>
`)
.join('');
if (this.selectedVoiceSet.size > 0) {
this.elements.voiceSearch.placeholder = 'Search voices...';
} else {
this.elements.voiceSearch.placeholder = 'Search and select voices...';
}
}
addSelectedVoice(voice) {
this.selectedVoiceSet.add(voice);
this.updateSelectedVoicesDisplay();
}
removeSelectedVoice(voice) {
this.selectedVoiceSet.delete(voice);
this.updateSelectedVoicesDisplay();
const checkbox = this.elements.voiceOptions.querySelector(`input[value="${voice}"]`);
if (checkbox) checkbox.checked = false;
}
filterVoices(searchTerm) {
const filtered = this.availableVoices.filter(voice =>
voice.toLowerCase().includes(searchTerm.toLowerCase())
);
this.renderVoiceOptions(filtered);
}
setupEventListeners() {
window.addEventListener('beforeunload', () => {
if (this.currentController) {
this.currentController.abort();
}
if (this.sound) {
this.sound.unload();
}
});
this.elements.voiceSearch.addEventListener('input', (e) => {
this.filterVoices(e.target.value);
});
this.elements.voiceOptions.addEventListener('change', (e) => {
if (e.target.type === 'checkbox') {
if (e.target.checked) {
this.addSelectedVoice(e.target.value);
} else {
this.removeSelectedVoice(e.target.value);
}
}
});
this.elements.selectedVoices.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-voice')) {
const voice = e.target.dataset.voice;
this.removeSelectedVoice(voice);
}
});
this.elements.generateBtn.addEventListener('click', () => this.generateSpeech());
this.elements.cancelBtn.addEventListener('click', () => this.cancelGeneration());
this.elements.playPauseBtn.addEventListener('click', () => this.togglePlayPause());
this.elements.downloadBtn.addEventListener('click', () => this.downloadAudio());
this.elements.speedSlider.addEventListener('input', (e) => {
const speed = parseFloat(e.target.value);
this.elements.speedValue.textContent = speed.toFixed(1);
});
document.addEventListener('click', (e) => {
if (!this.elements.voiceSearch.contains(e.target) &&
!this.elements.voiceDropdown.contains(e.target)) {
this.elements.voiceDropdown.style.display = 'none';
}
});
this.elements.voiceSearch.addEventListener('focus', () => {
this.elements.voiceDropdown.style.display = 'block';
if (!this.elements.voiceSearch.value) {
this.elements.voiceSearch.placeholder = 'Search voices...';
}
});
this.elements.voiceSearch.addEventListener('blur', () => {
if (!this.elements.voiceSearch.value && this.selectedVoiceSet.size === 0) {
this.elements.voiceSearch.placeholder = 'Search and select voices...';
}
});
window.addEventListener('resize', () => {
if (this.wave) {
this.wave.width = this.elements.waveContainer.clientWidth;
}
});
}
showStatus(message, type = 'info') {
this.elements.status.textContent = message;
this.elements.status.className = 'status ' + type;
setTimeout(() => {
this.elements.status.className = 'status';
}, 5000);
}
setLoading(loading) {
this.isGenerating = loading;
this.elements.generateBtn.disabled = loading;
this.elements.generateBtn.className = loading ? 'loading' : '';
this.elements.cancelBtn.style.display = loading ? 'block' : 'none';
}
validateInput() {
const text = this.elements.textInput.value.trim();
if (!text) {
this.showStatus('Please enter some text', 'error');
return false;
}
if (this.selectedVoiceSet.size === 0) {
this.showStatus('Please select a voice', 'error');
return false;
}
return true;
}
cancelGeneration() {
if (this.currentController) {
this.currentController.abort();
this.currentController = null;
if (this.sound) {
this.sound.unload();
this.sound = null;
}
this.wave.stop();
this.showStatus('Generation cancelled', 'info');
this.setLoading(false);
}
}
togglePlayPause() {
if (!this.sound) return;
if (this.sound.playing()) {
this.sound.pause();
this.wave.stop();
this.elements.playPauseBtn.textContent = 'Play';
} else {
this.sound.play();
this.wave.start();
this.elements.playPauseBtn.textContent = 'Pause';
this.updateTimeDisplay();
}
}
async generateSpeech() {
if (this.isGenerating || !this.validateInput()) return;
if (this.sound) {
this.sound.unload();
this.sound = null;
}
this.wave.stop();
this.elements.downloadBtn.style.display = 'none';
this.audioChunks = [];
const text = this.elements.textInput.value.trim();
const voice = Array.from(this.selectedVoiceSet).join('+');
this.setLoading(true);
this.currentController = new AbortController();
try {
await this.handleAudio(text, voice);
} catch (error) {
if (error.name === 'AbortError') {
this.showStatus('Generation cancelled', 'info');
} else {
this.showStatus('Error generating speech: ' + error.message, 'error');
}
} finally {
this.currentController = null;
this.setLoading(false);
}
}
async handleAudio(text, voice) {
this.showStatus('Generating audio...', 'info');
const response = await fetch('/v1/audio/speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input: text,
voice: voice,
response_format: 'mp3',
stream: true,
speed: parseFloat(this.elements.speedSlider.value)
}),
signal: this.currentController.signal
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail?.message || 'Failed to generate speech');
}
const chunks = [];
const reader = response.body.getReader();
let totalChunks = 0;
try {
while (true) {
const {value, done} = await reader.read();
if (done) {
this.showStatus('Processing complete', 'success');
break;
}
chunks.push(value);
this.audioChunks.push(value.slice(0));
totalChunks++;
if (totalChunks % 5 === 0) {
this.showStatus(`Received ${totalChunks} chunks...`, 'info');
}
}
const blob = new Blob(chunks, { type: 'audio/mpeg' });
const url = URL.createObjectURL(blob);
if (this.sound) {
this.sound.unload();
}
this.sound = new Howl({
src: [url],
format: ['mp3'],
html5: true,
onplay: () => {
this.elements.playPauseBtn.textContent = 'Pause';
this.wave.start();
this.updateTimeDisplay();
},
onpause: () => {
this.elements.playPauseBtn.textContent = 'Play';
this.wave.stop();
},
onend: () => {
this.elements.playPauseBtn.textContent = 'Play';
this.wave.stop();
this.elements.generateBtn.disabled = false;
},
onload: () => {
URL.revokeObjectURL(url);
this.showStatus('Audio ready', 'success');
this.enableDownload();
if (this.elements.autoplayToggle.checked) {
this.sound.play();
}
},
onloaderror: () => {
URL.revokeObjectURL(url);
this.showStatus('Error loading audio', 'error');
}
});
} catch (error) {
if (error.name === 'AbortError') {
throw error;
}
console.error('Streaming error:', error);
this.showStatus('Error during streaming', 'error');
throw error;
}
}
enableDownload() {
this.elements.downloadBtn.style.display = 'flex';
}
downloadAudio() {
if (this.audioChunks.length === 0) return;
const format = this.elements.formatSelect.value;
const voice = Array.from(this.selectedVoiceSet).join('+');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const blob = new Blob(this.audioChunks, { type: `audio/${format}` });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${voice}_${timestamp}.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
document.addEventListener('DOMContentLoaded', () => {
new KokoroPlayer();
});

View file

@ -15,8 +15,7 @@
<link rel="stylesheet" href="styles/player.css">
<link rel="stylesheet" href="styles/responsive.css">
<link rel="stylesheet" href="styles/badges.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/howler/2.2.4/howler.min.js"></script>
<script src="siriwave.js"></script>
<script src="https://unpkg.com/siriwave/dist/siriwave.umd.min.js"></script>
<script async defer src="https://buttons.github.io/buttons.js"></script>
</head>
<body>
@ -126,6 +125,6 @@
</div>
</main>
</div>
<script src="app.js"></script>
<script type="module" src="src/App.js"></script>
</body>
</html>

188
web/src/App.js Normal file
View file

@ -0,0 +1,188 @@
import AudioService from './services/AudioService.js';
import VoiceService from './services/VoiceService.js';
import PlayerState from './state/PlayerState.js';
import PlayerControls from './components/PlayerControls.js';
import VoiceSelector from './components/VoiceSelector.js';
import WaveVisualizer from './components/WaveVisualizer.js';
export class App {
constructor() {
this.elements = {
textInput: document.getElementById('text-input'),
generateBtn: document.getElementById('generate-btn'),
generateBtnText: document.querySelector('#generate-btn .btn-text'),
generateBtnLoader: document.querySelector('#generate-btn .loader'),
downloadBtn: document.getElementById('download-btn'),
autoplayToggle: document.getElementById('autoplay-toggle'),
formatSelect: document.getElementById('format-select'),
status: document.getElementById('status'),
cancelBtn: document.getElementById('cancel-btn')
};
this.initialize();
}
async initialize() {
// Initialize services and state
this.playerState = new PlayerState();
this.audioService = new AudioService();
this.voiceService = new VoiceService();
// Initialize components
this.playerControls = new PlayerControls(this.audioService, this.playerState);
this.voiceSelector = new VoiceSelector(this.voiceService);
this.waveVisualizer = new WaveVisualizer(this.playerState);
// Initialize voice selector
const voicesLoaded = await this.voiceSelector.initialize();
if (!voicesLoaded) {
this.showStatus('Failed to load voices', 'error');
this.elements.generateBtn.disabled = true;
return;
}
this.setupEventListeners();
this.setupAudioEvents();
}
setupEventListeners() {
// Generate button
this.elements.generateBtn.addEventListener('click', () => this.generateSpeech());
// Download button
this.elements.downloadBtn.addEventListener('click', () => this.downloadAudio());
// Cancel button
this.elements.cancelBtn.addEventListener('click', () => {
this.audioService.cancel();
this.setGenerating(false);
this.elements.downloadBtn.style.display = 'none';
this.showStatus('Generation cancelled', 'info');
});
// Handle page unload
window.addEventListener('beforeunload', () => {
this.audioService.cleanup();
this.playerControls.cleanup();
this.waveVisualizer.cleanup();
});
}
setupAudioEvents() {
// Handle download button visibility
this.audioService.addEventListener('downloadReady', () => {
this.elements.downloadBtn.style.display = 'flex';
});
// Handle completion
this.audioService.addEventListener('complete', () => {
this.setGenerating(false);
this.showStatus('Generation complete', 'success');
});
// Handle errors
this.audioService.addEventListener('error', (error) => {
this.showStatus('Error: ' + error.message, 'error');
this.setGenerating(false);
this.elements.downloadBtn.style.display = 'none';
});
}
showStatus(message, type = 'info') {
this.elements.status.textContent = message;
this.elements.status.className = 'status ' + type;
setTimeout(() => {
this.elements.status.className = 'status';
}, 5000);
}
setGenerating(isGenerating) {
this.playerState.setGenerating(isGenerating);
this.elements.generateBtn.disabled = isGenerating;
this.elements.generateBtn.className = isGenerating ? 'loading' : '';
this.elements.generateBtnLoader.style.display = isGenerating ? 'block' : 'none';
this.elements.generateBtnText.style.visibility = isGenerating ? 'hidden' : 'visible';
this.elements.cancelBtn.style.display = isGenerating ? 'block' : 'none';
}
validateInput() {
const text = this.elements.textInput.value.trim();
if (!text) {
this.showStatus('Please enter some text', 'error');
return false;
}
if (!this.voiceService.hasSelectedVoices()) {
this.showStatus('Please select a voice', 'error');
return false;
}
return true;
}
async generateSpeech() {
// Don't check isGenerating state since we want to allow generation after cancel
if (!this.validateInput()) {
return;
}
const text = this.elements.textInput.value.trim();
const voice = this.voiceService.getSelectedVoiceString();
const speed = this.playerState.getState().speed;
this.setGenerating(true);
this.elements.downloadBtn.style.display = 'none';
// Just reset progress bar, don't do full cleanup
this.waveVisualizer.updateProgress(0, 1);
try {
console.log('Starting audio generation...', { text, voice, speed });
// Ensure we have valid input
if (!text || !voice) {
console.error('Invalid input:', { text, voice, speed });
throw new Error('Invalid input parameters');
}
await this.audioService.streamAudio(
text,
voice,
speed,
(loaded, total) => {
console.log('Progress update:', { loaded, total });
this.waveVisualizer.updateProgress(loaded, total);
}
);
} catch (error) {
console.error('Generation error:', error);
if (error.name !== 'AbortError') {
this.showStatus('Error generating speech: ' + error.message, 'error');
this.setGenerating(false);
}
}
}
downloadAudio() {
const downloadUrl = this.audioService.getDownloadUrl();
if (!downloadUrl) return;
const format = this.elements.formatSelect.value;
const voice = this.voiceService.getSelectedVoiceString();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
// Create download link
const a = document.createElement('a');
a.href = downloadUrl;
a.download = `${voice}_${timestamp}.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
}
}
// Initialize app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new App();
});

View file

@ -0,0 +1,168 @@
export class PlayerControls {
constructor(audioService, playerState) {
this.audioService = audioService;
this.playerState = playerState;
this.elements = {
playPauseBtn: document.getElementById('play-pause-btn'),
seekSlider: document.getElementById('seek-slider'),
volumeSlider: document.getElementById('volume-slider'),
speedSlider: document.getElementById('speed-slider'),
speedValue: document.getElementById('speed-value'),
timeDisplay: document.getElementById('time-display'),
cancelBtn: document.getElementById('cancel-btn')
};
this.setupEventListeners();
this.setupAudioEvents();
this.setupStateSubscription();
this.timeUpdateInterval = null;
}
formatTime(secs) {
const minutes = Math.floor(secs / 60);
const seconds = Math.floor(secs % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
startTimeUpdate() {
this.stopTimeUpdate(); // Clear any existing interval
this.timeUpdateInterval = setInterval(() => {
this.updateTimeDisplay();
}, 100); // Update every 100ms for smooth tracking
}
stopTimeUpdate() {
if (this.timeUpdateInterval) {
clearInterval(this.timeUpdateInterval);
this.timeUpdateInterval = null;
}
}
updateTimeDisplay() {
const currentTime = this.audioService.getCurrentTime();
const duration = this.audioService.getDuration();
// Update time display
this.elements.timeDisplay.textContent =
`${this.formatTime(currentTime)} / ${this.formatTime(duration || 0)}`;
// Update seek slider
if (duration > 0 && !this.elements.seekSlider.dragging) {
this.elements.seekSlider.value = (currentTime / duration) * 100;
}
// Update state
this.playerState.setTime(currentTime, duration);
}
setupEventListeners() {
// Play/Pause button
this.elements.playPauseBtn.addEventListener('click', () => {
if (this.audioService.isPlaying()) {
this.audioService.pause();
} else {
this.audioService.play();
}
});
// Seek slider
this.elements.seekSlider.addEventListener('mousedown', () => {
this.elements.seekSlider.dragging = true;
});
this.elements.seekSlider.addEventListener('mouseup', () => {
this.elements.seekSlider.dragging = false;
});
this.elements.seekSlider.addEventListener('input', (e) => {
const duration = this.audioService.getDuration();
const seekTime = (duration * e.target.value) / 100;
this.audioService.seek(seekTime);
this.updateTimeDisplay();
});
// Volume slider
this.elements.volumeSlider.addEventListener('input', (e) => {
const volume = e.target.value / 100;
this.audioService.setVolume(volume);
this.playerState.setVolume(volume);
});
// Speed slider
this.elements.speedSlider.addEventListener('input', (e) => {
const speed = parseFloat(e.target.value);
this.elements.speedValue.textContent = speed.toFixed(1);
this.playerState.setSpeed(speed);
});
// Cancel button
this.elements.cancelBtn.addEventListener('click', () => {
this.audioService.cancel();
this.playerState.reset();
this.updateControls({ isGenerating: false });
this.stopTimeUpdate();
});
}
setupAudioEvents() {
this.audioService.addEventListener('play', () => {
this.elements.playPauseBtn.textContent = 'Pause';
this.playerState.setPlaying(true);
this.startTimeUpdate();
});
this.audioService.addEventListener('pause', () => {
this.elements.playPauseBtn.textContent = 'Play';
this.playerState.setPlaying(false);
this.stopTimeUpdate();
});
this.audioService.addEventListener('ended', () => {
this.elements.playPauseBtn.textContent = 'Play';
this.playerState.setPlaying(false);
this.stopTimeUpdate();
});
// Initial time display
this.updateTimeDisplay();
}
setupStateSubscription() {
this.playerState.subscribe(state => this.updateControls(state));
}
updateControls(state) {
// Update button states
this.elements.playPauseBtn.disabled = !state.duration && !state.isGenerating;
this.elements.seekSlider.disabled = !state.duration;
this.elements.cancelBtn.style.display = state.isGenerating ? 'block' : 'none';
// Update volume and speed if changed externally
if (this.elements.volumeSlider.value !== state.volume * 100) {
this.elements.volumeSlider.value = state.volume * 100;
}
if (this.elements.speedSlider.value !== state.speed.toString()) {
this.elements.speedSlider.value = state.speed;
this.elements.speedValue.textContent = state.speed.toFixed(1);
}
}
cleanup() {
this.stopTimeUpdate();
if (this.audioService) {
this.audioService.pause();
}
if (this.playerState) {
this.playerState.reset();
}
// Reset UI elements
this.elements.playPauseBtn.textContent = 'Play';
this.elements.playPauseBtn.disabled = true;
this.elements.seekSlider.value = 0;
this.elements.seekSlider.disabled = true;
this.elements.timeDisplay.textContent = '0:00 / 0:00';
}
}
export default PlayerControls;

View file

@ -0,0 +1,117 @@
export class VoiceSelector {
constructor(voiceService) {
this.voiceService = voiceService;
this.elements = {
voiceSearch: document.getElementById('voice-search'),
voiceDropdown: document.getElementById('voice-dropdown'),
voiceOptions: document.getElementById('voice-options'),
selectedVoices: document.getElementById('selected-voices')
};
this.setupEventListeners();
}
setupEventListeners() {
// Voice search
this.elements.voiceSearch.addEventListener('input', (e) => {
const filteredVoices = this.voiceService.filterVoices(e.target.value);
this.renderVoiceOptions(filteredVoices);
});
// Voice selection
this.elements.voiceOptions.addEventListener('change', (e) => {
if (e.target.type === 'checkbox') {
if (e.target.checked) {
this.voiceService.addVoice(e.target.value);
} else {
this.voiceService.removeVoice(e.target.value);
}
this.updateSelectedVoicesDisplay();
}
});
// Remove selected voice
this.elements.selectedVoices.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-voice')) {
const voice = e.target.dataset.voice;
this.voiceService.removeVoice(voice);
this.updateVoiceCheckbox(voice, false);
this.updateSelectedVoicesDisplay();
}
});
// Dropdown visibility
this.elements.voiceSearch.addEventListener('focus', () => {
this.elements.voiceDropdown.style.display = 'block';
this.updateSearchPlaceholder();
});
document.addEventListener('click', (e) => {
if (!this.elements.voiceSearch.contains(e.target) &&
!this.elements.voiceDropdown.contains(e.target)) {
this.elements.voiceDropdown.style.display = 'none';
}
});
this.elements.voiceSearch.addEventListener('blur', () => {
if (!this.elements.voiceSearch.value) {
this.updateSearchPlaceholder();
}
});
}
renderVoiceOptions(voices) {
this.elements.voiceOptions.innerHTML = voices
.map(voice => `
<label class="voice-option">
<input type="checkbox" value="${voice}"
${this.voiceService.getSelectedVoices().includes(voice) ? 'checked' : ''}>
${voice}
</label>
`)
.join('');
}
updateSelectedVoicesDisplay() {
const selectedVoices = this.voiceService.getSelectedVoices();
this.elements.selectedVoices.innerHTML = selectedVoices
.map(voice => `
<span class="selected-voice-tag">
${voice}
<span class="remove-voice" data-voice="${voice}">×</span>
</span>
`)
.join('');
this.updateSearchPlaceholder();
}
updateSearchPlaceholder() {
const hasSelected = this.voiceService.hasSelectedVoices();
this.elements.voiceSearch.placeholder = hasSelected ?
'Search voices...' :
'Search and select voices...';
}
updateVoiceCheckbox(voice, checked) {
const checkbox = this.elements.voiceOptions
.querySelector(`input[value="${voice}"]`);
if (checkbox) {
checkbox.checked = checked;
}
}
async initialize() {
try {
await this.voiceService.loadVoices();
this.renderVoiceOptions(this.voiceService.getAvailableVoices());
this.updateSelectedVoicesDisplay();
return true;
} catch (error) {
console.error('Failed to initialize voice selector:', error);
return false;
}
}
}
export default VoiceSelector;

View file

@ -0,0 +1,107 @@
export class WaveVisualizer {
constructor(playerState) {
this.playerState = playerState;
this.wave = null;
this.progressBar = null;
this.container = document.getElementById('wave-container');
this.setupWave();
this.setupProgressBar();
this.setupStateSubscription();
}
setupWave() {
this.wave = new SiriWave({
container: this.container,
style: 'ios9',
width: this.container.clientWidth,
height: 100, // Increased height
autostart: false,
amplitude: 1,
speed: 0.1
});
// Handle window resize
window.addEventListener('resize', () => {
if (this.wave) {
this.wave.width = this.container.clientWidth;
}
});
}
setupProgressBar() {
this.progressBar = document.createElement('progress');
this.progressBar.style.width = '100%';
this.progressBar.max = 100;
this.progressBar.value = 0;
this.progressBar.className = 'generation-progress';
// Insert inside player-container, after wave-container
const playerContainer = this.container.closest('.player-container');
playerContainer.insertBefore(this.progressBar, playerContainer.lastElementChild);
this.progressBar.style.display = 'none';
}
setupStateSubscription() {
this.playerState.subscribe(state => {
// Handle generation progress
if (state.isGenerating) {
this.progressBar.style.display = 'block';
this.progressBar.value = state.progress;
} else if (state.progress >= 100) {
// Hide progress bar after completion
setTimeout(() => {
this.progressBar.style.display = 'none';
this.progressBar.value = 0;
}, 500);
}
// Only animate when playing, stop otherwise
if (state.isPlaying) {
this.wave.start();
} else {
this.wave.stop();
}
});
}
updateProgress(receivedChunks, totalChunks) {
if (!totalChunks) return;
// Calculate progress percentage based on chunks
const progress = Math.min((receivedChunks / totalChunks) * 100, 99);
// Always update on 0 progress or when progress increases
if (receivedChunks === 0 || progress > this.progressBar.value) {
this.progressBar.style.display = 'block';
this.progressBar.value = progress;
this.playerState.setProgress(receivedChunks, totalChunks);
}
}
cleanup() {
if (this.wave) {
this.wave.stop();
this.wave.dispose();
this.wave = null;
}
if (this.progressBar) {
this.progressBar.style.display = 'none';
this.progressBar.value = 0;
if (this.progressBar.parentNode) {
this.progressBar.parentNode.removeChild(this.progressBar);
}
this.progressBar = null;
}
// Re-setup wave and progress bar
this.setupWave();
this.setupProgressBar();
if (this.playerState) {
this.playerState.setProgress(0, 1); // Reset progress in state
}
}
}
export default WaveVisualizer;

View file

@ -0,0 +1,295 @@
export class AudioService {
constructor() {
this.mediaSource = null;
this.sourceBuffer = null;
this.audio = null;
this.controller = null;
this.eventListeners = new Map();
this.chunks = [];
this.minimumPlaybackSize = 50000; // 50KB minimum before playback
this.textLength = 0;
this.shouldAutoplay = false;
this.CHARS_PER_CHUNK = 600; // Estimated chars per chunk
}
async streamAudio(text, voice, speed, onProgress) {
try {
console.log('AudioService: Starting stream...', { text, voice, speed });
// Only abort if there's an active controller
if (this.controller) {
this.controller.abort();
this.controller = null;
}
// Create new controller before cleanup to prevent race conditions
this.controller = new AbortController();
// Clean up previous audio state
this.cleanup();
onProgress?.(0, 1); // Reset progress to 0
this.chunks = [];
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',
stream: true,
speed: speed
}),
signal: this.controller.signal
});
console.log('AudioService: Got response', { status: response.status });
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, onProgress, estimatedChunks);
return this.audio;
} catch (error) {
this.cleanup();
throw error;
}
}
async setupAudioStream(response, onProgress, estimatedTotalSize) {
this.audio = new Audio();
this.mediaSource = new MediaSource();
this.audio.src = URL.createObjectURL(this.mediaSource);
return new Promise((resolve, reject) => {
this.mediaSource.addEventListener('sourceopen', async () => {
try {
this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg');
await this.processStream(response.body, onProgress, estimatedTotalSize);
resolve();
} catch (error) {
reject(error);
}
});
});
}
async processStream(stream, onProgress, estimatedChunks) {
const reader = stream.getReader();
let hasStartedPlaying = false;
let receivedChunks = 0;
try {
while (true) {
const {value, done} = await reader.read();
if (done) {
if (this.mediaSource.readyState === 'open') {
this.mediaSource.endOfStream();
}
// Ensure we show 100% at completion
onProgress?.(estimatedChunks, estimatedChunks);
this.dispatchEvent('complete');
this.dispatchEvent('downloadReady');
return;
}
this.chunks.push(value);
receivedChunks++;
await this.appendChunk(value);
// Update progress based on received chunks
onProgress?.(receivedChunks, estimatedChunks);
// Start playback if we have enough chunks
if (!hasStartedPlaying && receivedChunks >= 1) {
hasStartedPlaying = true;
if (this.shouldAutoplay) {
// Small delay to ensure buffer is ready
setTimeout(() => this.play(), 100);
}
}
}
} catch (error) {
if (error.name !== 'AbortError') {
throw error;
}
}
}
async appendChunk(chunk) {
return new Promise((resolve) => {
const appendChunk = () => {
this.sourceBuffer.appendBuffer(chunk);
this.sourceBuffer.addEventListener('updateend', resolve, { once: true });
};
if (!this.sourceBuffer.updating) {
appendChunk();
} else {
this.sourceBuffer.addEventListener('updateend', appendChunk, { once: true });
}
});
}
play() {
if (this.audio && this.audio.readyState >= 2) {
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) {
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;
}
// Full cleanup of all resources
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;
this.sourceBuffer = null;
this.chunks = [];
this.textLength = 0;
// Force a hard refresh of the page to ensure clean state
window.location.reload();
}
cleanup() {
// Clean up audio elements
if (this.audio) {
// Remove all event listeners
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;
this.sourceBuffer = null;
this.chunks = [];
this.textLength = 0;
}
getDownloadUrl() {
if (!this.audio || !this.sourceBuffer || this.chunks.length === 0) return null;
// Get the buffered data from MediaSource
const buffered = this.sourceBuffer.buffered;
if (buffered.length === 0) return null;
// Create blob from the original chunks
const blob = new Blob(this.chunks, { type: 'audio/mpeg' });
return URL.createObjectURL(blob);
}
}
export default AudioService;

View file

@ -0,0 +1,81 @@
export class VoiceService {
constructor() {
this.availableVoices = [];
this.selectedVoices = new Set();
}
async loadVoices() {
try {
const response = await fetch('/v1/audio/voices');
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail?.message || 'Failed to load voices');
}
const data = await response.json();
if (!data.voices?.length) {
throw new Error('No voices available');
}
this.availableVoices = data.voices;
// Select first voice if none selected
if (this.selectedVoices.size === 0) {
const firstVoice = this.availableVoices.find(voice => voice && voice.trim());
if (firstVoice) {
this.addVoice(firstVoice);
}
}
return this.availableVoices;
} catch (error) {
console.error('Failed to load voices:', error);
throw error;
}
}
getAvailableVoices() {
return this.availableVoices;
}
getSelectedVoices() {
return Array.from(this.selectedVoices);
}
getSelectedVoiceString() {
return Array.from(this.selectedVoices).join('+');
}
addVoice(voice) {
if (this.availableVoices.includes(voice)) {
this.selectedVoices.add(voice);
return true;
}
return false;
}
removeVoice(voice) {
return this.selectedVoices.delete(voice);
}
clearSelectedVoices() {
this.selectedVoices.clear();
}
filterVoices(searchTerm) {
if (!searchTerm) {
return this.availableVoices;
}
const term = searchTerm.toLowerCase();
return this.availableVoices.filter(voice =>
voice.toLowerCase().includes(term)
);
}
hasSelectedVoices() {
return this.selectedVoices.size > 0;
}
}
export default VoiceService;

View file

@ -0,0 +1,88 @@
export class PlayerState {
constructor() {
this.state = {
isPlaying: false,
isGenerating: false,
currentTime: 0,
duration: 0,
volume: 1,
speed: 1,
progress: 0,
error: null
};
this.listeners = new Set();
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
notify() {
this.listeners.forEach(listener => listener(this.state));
}
setState(updates) {
this.state = {
...this.state,
...updates
};
this.notify();
}
setPlaying(isPlaying) {
this.setState({ isPlaying });
}
setGenerating(isGenerating) {
this.setState({ isGenerating });
}
setProgress(loaded, total) {
const progress = total > 0 ? (loaded / total) * 100 : 0;
this.setState({ progress });
}
setTime(currentTime, duration) {
this.setState({ currentTime, duration });
}
setVolume(volume) {
this.setState({ volume });
}
setSpeed(speed) {
this.setState({ speed });
}
setError(error) {
this.setState({ error });
}
clearError() {
this.setState({ error: null });
}
reset() {
// Keep current speed setting but reset everything else
const currentSpeed = this.state.speed;
const currentVolume = this.state.volume;
this.setState({
isPlaying: false,
isGenerating: false,
currentTime: 0,
duration: 0,
progress: 0,
error: null,
speed: currentSpeed,
volume: currentVolume
});
}
getState() {
return { ...this.state };
}
}
export default PlayerState;

View file

@ -122,6 +122,45 @@
margin-top: 0.5rem;
}
/* Progress bar styles */
.generation-progress {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border: none;
background: rgba(99, 102, 241, 0.1);
border-radius: 3px;
margin: 1rem 0;
display: block;
}
.generation-progress::-webkit-progress-bar {
background: rgba(99, 102, 241, 0.1);
border-radius: 3px;
}
.generation-progress::-webkit-progress-value {
background: var(--fg-color);
border-radius: 3px;
transition: width 0.2s ease;
box-shadow: 0 0 10px rgba(99, 102, 241, 0.3);
}
.generation-progress::-moz-progress-bar {
background: var(--fg-color);
border-radius: 3px;
transition: width 0.2s ease;
box-shadow: 0 0 10px rgba(99, 102, 241, 0.3);
}
.generation-progress::-ms-fill {
background: var(--fg-color);
border-radius: 3px;
transition: width 0.2s ease;
box-shadow: 0 0 10px rgba(99, 102, 241, 0.3);
}
.wave-container canvas {
position: absolute;
top: 0;