Files
spark-pocket-tts/pocket_tts/static/index.html
T
2026-01-13 11:30:33 +01:00

374 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pocket TTS - Streaming</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<div class="max-w-xl mx-auto p-4 space-y-4">
<h1 class="text-xl font-bold text-center">Pocket TTS</h1>
<div class="bg-white p-4 rounded-lg shadow">
<textarea
id="text-input"
class="w-full border rounded p-2"
placeholder="Enter your text here..."
rows="4"
>Hello world. I am Kyutai's Pocket TTS. I'm fast enough to run on small CPUs. I hope you'll like me.</textarea>
</div>
<div class="bg-white p-4 rounded-lg shadow">
<label for="voice-url-input" class="block text-sm font-medium text-gray-700 mb-2">
Optional voice URL (leave empty to use default voice):
</label>
<input
type="text"
id="voice-url-input"
class="w-full border rounded p-2"
placeholder="hf://kyutai/tts-voices/alba-mackenna/casual.wav"
value="alba"
/>
<p class="text-xs text-gray-500 mt-1">
Supports: http://, https://, or hf:// URLs.<br>
You can also use predefined voices:<br>
"alba", "marius", "javert", "jean", "fantine", "cosette", "eponine", "azelma".
</p>
</div>
<div class="bg-white p-4 rounded-lg shadow">
<label for="voice-wav-input" class="block text-sm font-medium text-gray-700 mb-2">
Or upload a WAV file for voice cloning:
</label>
<input
type="file"
id="voice-wav-input"
class="w-full border rounded p-2"
accept=".wav,audio/wav"
/>
<p class="text-xs text-gray-500 mt-1">
Upload a WAV file to use as voice reference. Takes precedence over voice URL.
</p>
</div>
<button
id="generate-btn"
class="w-full px-4 py-2 rounded text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
<span id="generate-text">Generate audio</span>
</button>
<div id="status" class="hidden">
<div class="flex items-center space-x-2">
<div id="status-spinner" class="spinner w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full"></div>
<span id="status-text"></span>
</div>
</div>
<div id="audio-section" class="hidden">
<div class="bg-white p-4 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-2">Generated Audio</h3>
<audio id="audio" controls class="w-full"></audio>
<div class="mt-2">
<a id="download-link" class="text-blue-600 text-sm hover:underline">Download</a>
</div>
</div>
</div>
</div>
<script>
class StreamingWavPlayer {
constructor() {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.sampleRate = 0;
this.numChannels = 0;
this.headerParsed = false;
this.headerBuffer = new Uint8Array(44);
this.headerBytesReceived = 0;
this.nextStartTime = 0;
this.isPlaying = false;
this.minBufferSize = 16384;
this.pcmData = new Uint8Array(0);
}
parseWavHeader(header) {
const view = new DataView(header.buffer);
const riff = String.fromCharCode.apply(null, Array.from(header.slice(0, 4)));
const wave = String.fromCharCode.apply(null, Array.from(header.slice(8, 12)));
if (riff !== 'RIFF' || wave !== 'WAVE') {
throw new Error('Invalid WAV file');
}
this.numChannels = view.getUint16(22, true);
this.sampleRate = view.getUint32(24, true);
const bitsPerSample = view.getUint16(34, true);
console.log(`WAV Format: ${this.sampleRate}Hz, ${this.numChannels} channels, ${bitsPerSample} bits`);
this.headerParsed = true;
}
appendPcmData(newData) {
const newBuffer = new Uint8Array(this.pcmData.length + newData.length);
newBuffer.set(this.pcmData);
newBuffer.set(newData, this.pcmData.length);
this.pcmData = newBuffer;
}
async tryPlayBuffer() {
if (!this.headerParsed || this.pcmData.length < this.minBufferSize) {
return;
}
const bytesPerSample = this.numChannels * 2;
const samplesToPlay = Math.floor(this.pcmData.length / bytesPerSample);
const bytesToPlay = samplesToPlay * bytesPerSample;
if (bytesToPlay === 0) return;
const dataToPlay = this.pcmData.slice(0, bytesToPlay);
this.pcmData = this.pcmData.slice(bytesToPlay);
const audioBuffer = this.audioContext.createBuffer(
this.numChannels,
samplesToPlay,
this.sampleRate
);
const int16Data = new Int16Array(dataToPlay.buffer, dataToPlay.byteOffset, samplesToPlay * this.numChannels);
for (let channel = 0; channel < this.numChannels; channel++) {
const channelData = audioBuffer.getChannelData(channel);
for (let i = 0; i < samplesToPlay; i++) {
channelData[i] = int16Data[i * this.numChannels + channel] / 32768;
}
}
const source = this.audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.audioContext.destination);
const currentTime = this.audioContext.currentTime;
const startTime = Math.max(currentTime, this.nextStartTime);
source.start(startTime);
// Track first audio playback
if (!this.firstAudioPlayed && window.firstAudioCallback) {
this.firstAudioPlayed = true;
window.firstAudioCallback();
}
this.nextStartTime = startTime + audioBuffer.duration;
this.isPlaying = true;
if (this.pcmData.length >= this.minBufferSize) {
setTimeout(() => this.tryPlayBuffer(), 10);
}
}
addChunk(chunk) {
if (!this.headerParsed) {
const headerBytesNeeded = 44 - this.headerBytesReceived;
const bytesToCopy = Math.min(headerBytesNeeded, chunk.length);
this.headerBuffer.set(
chunk.slice(0, bytesToCopy),
this.headerBytesReceived
);
this.headerBytesReceived += bytesToCopy;
if (this.headerBytesReceived >= 44) {
this.parseWavHeader(this.headerBuffer);
if (chunk.length > bytesToCopy) {
this.appendPcmData(chunk.slice(bytesToCopy));
}
}
} else {
this.appendPcmData(chunk);
}
this.tryPlayBuffer();
}
stop() {
this.audioContext.close();
this.isPlaying = false;
}
}
// Application state
let streamingPlayer = null;
let currentAudioBlob = null;
// DOM elements
const textInput = document.getElementById('text-input');
const generateBtn = document.getElementById('generate-btn');
const generateText = document.getElementById('generate-text');
const status = document.getElementById('status');
const statusText = document.getElementById('status-text');
const statusSpinner = document.getElementById('status-spinner');
const audioSection = document.getElementById('audio-section');
const audio = document.getElementById('audio');
const downloadLink = document.getElementById('download-link');
// Event listeners
generateBtn.addEventListener('click', generateAudio);
function showStatus(message, isLoading = false) {
statusText.textContent = message;
statusSpinner.style.display = isLoading ? 'block' : 'none';
status.classList.remove('hidden');
}
function hideStatus() {
status.classList.add('hidden');
}
async function generateAudio() {
const text = textInput.value.trim();
if (!text) {
showStatus('Please enter some text to generate speech.', false);
setTimeout(hideStatus, 3000);
return;
}
// Stop any currently playing audio
if (streamingPlayer) {
streamingPlayer.stop();
streamingPlayer = null;
}
// Track timing
const startTime = performance.now();
let firstAudioTime = null;
// Set callback for first audio
window.firstAudioCallback = () => {
if (!firstAudioTime) {
firstAudioTime = performance.now();
const timeToFirstAudio = ((firstAudioTime - startTime) / 1000).toFixed(2);
showStatus(`First audio in ${timeToFirstAudio}s...`, true);
}
};
// Update UI
generateBtn.disabled = true;
generateText.textContent = 'Generating...';
showStatus('Generating speech...', true);
audioSection.classList.add('hidden');
try {
const formData = new FormData();
formData.append('text', text);
// Add voice URL if provided (only if no WAV file is uploaded)
const voiceUrl = document.getElementById('voice-url-input').value.trim();
const voiceWavFile = document.getElementById('voice-wav-input').files[0];
if (voiceWavFile) {
// If WAV file is uploaded, only use the WAV file (ignore voice URL)
formData.append('voice_wav', voiceWavFile);
} else if (voiceUrl) {
// Only use voice URL if no WAV file is uploaded
formData.append('voice_url', voiceUrl);
}
const response = await fetch('/tts', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
// Clone the response for both streaming and blob collection
const responseForPlayback = response.clone();
const responseForHistory = response.clone();
// Start streaming playback
const reader = responseForPlayback.body.getReader();
streamingPlayer = new StreamingWavPlayer();
const processStream = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
streamingPlayer.addChunk(value);
}
}
} catch (e) {
console.error('Error processing stream:', e);
}
};
// Start processing the stream
processStream();
// Also collect the full blob for download/audio element
const blob = await responseForHistory.blob();
const totalTime = ((performance.now() - startTime) / 1000).toFixed(2);
currentAudioBlob = blob;
// Update UI with audio
const audioUrl = URL.createObjectURL(blob);
audio.src = audioUrl;
// Wait for audio metadata to load to get duration
audio.addEventListener('loadedmetadata', () => {
const audioDuration = audio.duration;
const speedRatio = (audioDuration / parseFloat(totalTime)).toFixed(1);
const timeToFirst = firstAudioTime ? ((firstAudioTime - startTime) / 1000).toFixed(2) : 'N/A';
showStatus(
`✨ First audio: ${timeToFirst}s | Total: ${totalTime}s | ${speedRatio}x faster than real-time`,
false
);
});
downloadLink.href = audioUrl;
downloadLink.download = `tts-audio.wav`;
audioSection.classList.remove('hidden');
} catch (error) {
console.error('Error generating audio:', error);
showStatus(`Error: ${error.message}`, false);
setTimeout(hideStatus, 3000);
} finally {
// Re-enable button
generateBtn.disabled = false;
generateText.textContent = 'Generate Audio';
}
}
// Focus on text input when page loads
window.addEventListener('load', () => {
textInput.focus();
});
</script>
</body>
</html>