tts-voice-creator-clone-and.../static/js/stt.js
mARTin-B78 a0d94b140e Fix clone UX: sample text, live monitor, quality, STT picker, OGG accept
1. Sample text: expose initCloneSampleText as a window function and call
   it from nav.js runSideEffects when the clone section is activated,
   ensuring the textarea is always populated even if the IIFE ran before
   the element existed.

2. Better sample texts: all 8 languages rewritten to ~38 words / ~15 s,
   first-person, phonetically rich, proper Unicode diacritics.

3. Live mic monitor: a level-meter (18-bar) + scrolling oscilloscope
   canvas (ring-buffer, 300 px, colour-coded) added to the microphone
   card.  "Check level" / "Stop monitor" buttons start/stop it
   independently; clicking Record starts it automatically.
   Uses raw mic constraints (no echo-cancel / AGC) for cleaner voice clone
   audio.  Mic gain slider and dB readout included.

4. Recording quality: MediaRecorder now requests audioBitsPerSecond:256000
   in both voice-clone.js and stt.js.

5. STT engine picker: Recognition engine <select> + Refresh button added
   above the Auto-transcribe button in Step 3.  refreshSttBackends() now
   syncs both stt-tts-stt-backend and clone-stt-backend.  The transcribe
   call passes the chosen backend to /api/transcribe.

6. File input: explicit extension list added to accept= for OGG/OPUS.

7. CSS: .mic-live-wave style added (dark/light theme variants).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 17:58:28 +02:00

289 lines
11 KiB
JavaScript

// ── STT -> TTS ───────────────────────────────────────────────────────────
let sttTtsSourceId = null;
let sttTtsOutputBlob = null;
let _sttBackends = [];
let sttTtsRecorder = null;
let sttTtsRecordStream = null;
let sttTtsRecordChunks = [];
let sttTtsRecordTimer = null;
let sttTtsRecordSecs = 0;
function sttTtsSelectedSttBackend() {
return $('stt-tts-stt-backend')?.value || 'configured';
}
function sttBackendOptionHtml(selected = 'configured') {
if (!_sttBackends.length) return '<option value="configured">Configured Whisper/STT</option>';
const preferred = _sttBackends.some(b => b.id === selected && b.available) ? selected : (_sttBackends.find(b => b.available)?.id || selected);
return _sttBackends.map(b => {
const suffix = b.available ? '' : ' (unavailable)';
const disabled = b.available ? '' : ' disabled';
return `<option value="${escHtml(b.id)}" ${b.id === preferred ? 'selected' : ''}${disabled}>${escHtml(b.label + suffix)}</option>`;
}).join('');
}
function updateSttBackendHelp() {
const selected = sttTtsSelectedSttBackend();
const b = _sttBackends.find(item => item.id === selected) || _sttBackends.find(item => item.available) || null;
const help = $('stt-tts-stt-help');
if (!help) return;
if (!b) { help.textContent = 'No STT engine status loaded yet.'; return; }
help.innerHTML = sttBackendHelpHtml(b);
}
async function refreshSttBackends(selected = '') {
try {
const d = await fetch('/api/stt-backends').then(r => r.json());
_sttBackends = (d.backends || []).filter(b => b && b.id);
} catch (_) {
_sttBackends = [];
}
['stt-tts-stt-backend', 'clone-stt-backend'].forEach(id => {
const sel = $(id);
if (!sel) return;
const prev = selected || sel.value || 'configured';
sel.innerHTML = sttBackendOptionHtml(prev);
sel.disabled = !_sttBackends.some(b => b.available);
});
updateSttBackendHelp();
}
function sttTtsSelectedBackend() {
return $('stt-tts-backend-select')?.value || '';
}
function sttTtsDownload(blob, name) {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
}
async function sttTtsUploadFile(file) {
if (!file) return;
$('stt-tts-source-status').textContent = 'Uploading ' + file.name + '...';
sttTtsSourceId = null;
sttTtsOutputBlob = null;
$('stt-tts-transcribe-btn').disabled = true;
$('stt-tts-copy-preview-btn').disabled = true;
$('stt-tts-save-mp3-btn').disabled = true;
$('stt-tts-save-wav-btn').disabled = true;
const fd = new FormData();
fd.append('file', file);
try {
const r = await fetch('/api/upload', {method:'POST', body:fd});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
sttTtsSourceId = d.id;
const audio = $('stt-tts-source-audio');
audio.src = '/api/audio/' + encodeURIComponent(d.id);
audio.style.display = '';
$('stt-tts-source-status').textContent = `${d.filename || file.name} loaded (${Number(d.duration || 0).toFixed(1)} s).`;
$('stt-tts-transcribe-btn').disabled = false;
toast('Speech audio loaded', 'success');
} catch (e) {
$('stt-tts-source-status').textContent = 'Upload failed.';
toast('STT source upload failed: ' + e.message, 'error');
}
}
async function sttTtsFetchVoices() {
const backend = sttTtsSelectedBackend();
if (!backend) throw new Error('No available TTS backend');
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
let voices = Array.isArray(rawVoices) ? rawVoices : [];
if (shouldFilterBackendVoices(backend)) {
const activeIds = await activeLibraryVoiceIds();
voices = voices.filter(v => activeIds.has(backendVoiceId(v)));
}
const sel = $('stt-tts-voice-select'), prev = sel.value;
sel.innerHTML = '<option value="">-- select after fetch --</option>';
voices.forEach(v => {
const id = backendVoiceId(v);
const opt = document.createElement('option');
opt.value = opt.textContent = id;
sel.appendChild(opt);
});
if (prev && voices.some(v => backendVoiceId(v) === prev)) sel.value = prev;
return voices.length;
}
$('stt-tts-file')?.addEventListener('change', async () => {
const input = $('stt-tts-file');
if (input.files && input.files.length) await sttTtsUploadFile(input.files[0]);
input.value = '';
});
$('stt-tts-refresh-stt-btn')?.addEventListener('click', async () => {
const btn = $('stt-tts-refresh-stt-btn');
btn.disabled = true;
try {
await refreshSttBackends(sttTtsSelectedSttBackend());
toast('STT engines refreshed', 'success');
} finally {
btn.disabled = false;
}
});
$('stt-tts-stt-backend')?.addEventListener('change', updateSttBackendHelp);
function sttTtsSetRecording(on) {
$('stt-tts-rec-start').disabled = on;
$('stt-tts-rec-stop').disabled = !on;
}
function sttTtsStopTracks() {
if (sttTtsRecordStream) sttTtsRecordStream.getTracks().forEach(t => t.stop());
sttTtsRecordStream = null;
}
$('stt-tts-rec-start')?.addEventListener('click', async () => {
try {
sttTtsRecordStream = await requestMicrophoneStream();
sttTtsRecordChunks = [];
sttTtsRecordSecs = 0;
$('stt-tts-rec-time').textContent = '0:00';
$('stt-tts-source-status').textContent = 'Recording...';
sttTtsSetRecording(true);
sttTtsRecordTimer = setInterval(() => {
sttTtsRecordSecs++;
$('stt-tts-rec-time').textContent = Math.floor(sttTtsRecordSecs / 60) + ':' + String(sttTtsRecordSecs % 60).padStart(2, '0');
}, 1000);
sttTtsRecorder = new MediaRecorder(sttTtsRecordStream, {audioBitsPerSecond: 256000});
sttTtsRecorder.ondataavailable = e => { if (e.data.size) sttTtsRecordChunks.push(e.data); };
sttTtsRecorder.onstop = async () => {
clearInterval(sttTtsRecordTimer);
sttTtsRecordTimer = null;
sttTtsSetRecording(false);
sttTtsStopTracks();
const mime = sttTtsRecorder.mimeType || 'audio/webm';
const blob = new Blob(sttTtsRecordChunks, {type:mime});
const ext = mime.includes('ogg') ? '.ogg' : '.webm';
if (!blob.size) {
$('stt-tts-source-status').textContent = 'Recording was empty.';
toast('Recording was empty', 'error');
return;
}
await sttTtsUploadFile(new File([blob], 'stt-recording' + ext, {type:mime}));
};
sttTtsRecorder.start(100);
toast('Recording started', 'success');
} catch (e) {
sttTtsSetRecording(false);
sttTtsStopTracks();
const message = await microphoneErrorMessage(e);
$('stt-tts-source-status').textContent = message;
toast(message, 'error');
}
});
$('stt-tts-rec-stop')?.addEventListener('click', () => {
if (sttTtsRecorder && sttTtsRecorder.state !== 'inactive') sttTtsRecorder.stop();
});
$('stt-tts-transcribe-btn')?.addEventListener('click', async () => {
if (!sttTtsSourceId) { toast('Load speech audio first', 'error'); return; }
const btn = $('stt-tts-transcribe-btn');
btn.disabled = true;
$('stt-tts-source-status').textContent = 'Transcribing...';
try {
const r = await fetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:sttTtsSourceId, backend:sttTtsSelectedSttBackend()})});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
$('stt-tts-text').value = d.text || '';
$('stt-tts-copy-preview-btn').disabled = !(d.text || '').trim();
const used = d.backend ? ' via ' + d.backend : '';
$('stt-tts-source-status').textContent = 'Transcription ready' + used + '.';
if (typeof updateRefineButtonState === 'function') updateRefineButtonState();
toast('Transcription ready', 'success');
} catch (e) {
$('stt-tts-source-status').textContent = 'Transcription failed.';
toast('STT failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
$('stt-tts-copy-preview-btn')?.addEventListener('click', () => {
const text = $('stt-tts-text').value.trim();
if (!text) return;
$('preview-text-area').value = text;
switchTab('generation');
toast('Copied transcription to TTS Generation', 'success');
});
$('stt-tts-backend-select')?.addEventListener('change', () => {
$('stt-tts-voice-select').innerHTML = '<option value="">-- select after fetch --</option>';
sttTtsOutputBlob = null;
$('stt-tts-save-mp3-btn').disabled = true;
$('stt-tts-save-wav-btn').disabled = true;
updateBackendHelp();
});
$('stt-tts-fetch-voices-btn')?.addEventListener('click', async () => {
const btn = $('stt-tts-fetch-voices-btn');
btn.disabled = true;
try {
const count = await sttTtsFetchVoices();
toast('Fetched ' + count + ' voices', 'success');
} catch (e) {
toast('Fetch failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
$('stt-tts-generate-btn')?.addEventListener('click', async () => {
const backend = sttTtsSelectedBackend();
const voice = $('stt-tts-voice-select').value;
const text = $('stt-tts-text').value.trim();
const instruct = $('stt-tts-style-instruction').value.trim();
if (!backend) { toast('No available TTS backend', 'error'); return; }
if (!voice) { toast('Select a TTS voice', 'error'); return; }
if (!text) { toast('Transcribe or enter text first', 'error'); return; }
const btn = $('stt-tts-generate-btn');
btn.disabled = true;
$('stt-tts-save-mp3-btn').disabled = true;
$('stt-tts-save-wav-btn').disabled = true;
try {
const source = await createTtsAudioSource(voice, text, backend, $('stt-tts-playback-mode').value, instruct);
sttTtsOutputBlob = source.blob;
const audio = $('stt-tts-output-audio');
audio.src = source.url;
audio.style.display = '';
await audio.play();
$('stt-tts-save-mp3-btn').disabled = false;
$('stt-tts-save-wav-btn').disabled = source.streaming;
toast(source.streaming ? 'Streaming synthesized speech' : 'Synthesized speech ready', 'success');
} catch (e) {
toast('TTS failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
$('stt-tts-save-mp3-btn')?.addEventListener('click', async () => {
const backend = sttTtsSelectedBackend();
const voice = $('stt-tts-voice-select').value;
const text = $('stt-tts-text').value.trim();
const instruct = $('stt-tts-style-instruction').value.trim();
if (!backend || !voice || !text) return;
const btn = $('stt-tts-save-mp3-btn');
btn.disabled = true;
try {
const blob = await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, backend);
sttTtsDownload(blob, (voice || 'stt_tts') + '_stt_tts.mp3');
toast('MP3 saved', 'success');
} catch (e) {
toast('MP3 save failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
$('stt-tts-save-wav-btn')?.addEventListener('click', () => {
if (!sttTtsOutputBlob) return;
sttTtsDownload(sttTtsOutputBlob, ($('stt-tts-voice-select').value || 'stt_tts') + '_stt_tts.wav');
});