Kokoro-FastAPI/web/app.js

445 lines
No EOL
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
});