Security fixes: - Block /proc /sys /dev /run /boot in /api/browse-dirs (path traversal) - Verify yt-dlp output stays inside TEMP_DIR before registration - Remove Access-Control-Allow-Origin: * from /api/proxy-audio - TTL-based temp file registry (default 2h) to prevent disk fill Performance: - Cache settings + routing rules in memory (mtime-checked); eliminates per-request disk reads on every TTS call UI: - Add container name (optional) field to Docker stack TTS/STT engine cards (Qwen3 Voice Clone, Voice Design, Custom Voice, Streaming, NVIDIA Magpie, Parakeet) — enables Stop/Start/Restart buttons on all engine cards, matching the existing Other Local TTS/STT cards Refactor — backend: - server.py: 5560 lines → 43-line entry point - core/ package: constants, registry, validation, docker_client, config, routing, audio, voice, presets, tts_helpers - routes/ package: admin, settings, library, stt, sources, docker, tts, conversation (FastAPI APIRouter modules) - Dockerfile + docker-compose.yml updated to include core/ and routes/ Refactor — frontend: - static/app.js: 8744 lines → 16 modules in static/js/ utils, voice-inspector, voice-sources, integrations, routing, settings, voice-clone, voice-library, tts-preview, benchmark, stt, init, engines, ai-backends, generation, conversation - static/loader.js updated to load modules sequentially Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
288 lines
11 KiB
JavaScript
288 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 = [];
|
|
}
|
|
const sel = $('stt-tts-stt-backend');
|
|
if (sel) {
|
|
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);
|
|
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');
|
|
});
|
|
|