mirror of
https://github.com/remsky/Kokoro-FastAPI.git
synced 2025-04-13 09:39:17 +00:00
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:
parent
75889e157d
commit
18b15728a8
12 changed files with 1106 additions and 450 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
445
web/app.js
445
web/app.js
|
@ -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();
|
||||
});
|
|
@ -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
188
web/src/App.js
Normal 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();
|
||||
});
|
168
web/src/components/PlayerControls.js
Normal file
168
web/src/components/PlayerControls.js
Normal 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;
|
117
web/src/components/VoiceSelector.js
Normal file
117
web/src/components/VoiceSelector.js
Normal 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;
|
107
web/src/components/WaveVisualizer.js
Normal file
107
web/src/components/WaveVisualizer.js
Normal 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;
|
295
web/src/services/AudioService.js
Normal file
295
web/src/services/AudioService.js
Normal 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;
|
81
web/src/services/VoiceService.js
Normal file
81
web/src/services/VoiceService.js
Normal 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;
|
88
web/src/state/PlayerState.js
Normal file
88
web/src/state/PlayerState.js
Normal 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;
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue