diff --git a/routes/admin.py b/routes/admin.py index 23f5e28..95cb9de 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -52,6 +52,14 @@ async def get_version(): return {"version": __version__} +@router.get("/api/changelog", response_class=PlainTextResponse) +async def get_changelog(): + cl = Path(__file__).parent.parent / "CHANGELOG.md" + if not cl.is_file(): + raise HTTPException(404, "CHANGELOG.md not found") + return cl.read_text(encoding="utf-8") + + @router.get("/robots.txt", response_class=PlainTextResponse) async def robots_txt(): return "User-agent: *\nDisallow: /" diff --git a/static/index.html b/static/index.html index fa8ee3f..dc8b098 100644 --- a/static/index.html +++ b/static/index.html @@ -117,6 +117,7 @@ + @@ -229,6 +230,7 @@ + diff --git a/static/js/conversation.js b/static/js/conversation.js index fe15614..4ffa45a 100644 --- a/static/js/conversation.js +++ b/static/js/conversation.js @@ -70,6 +70,23 @@ document.querySelectorAll('.s-log-filter').forEach(btn => { } catch (_) {} })(); +// Lazy-load changelog when the details element is opened +$('about-changelog-details')?.addEventListener('toggle', async function () { + if (!this.open) return; + const content = $('about-changelog-content'); + const status = $('about-changelog-status'); + if (!content || content.textContent.trim()) return; + if (status) status.textContent = 'Loading…'; + try { + const text = await fetch('/api/changelog').then(r => r.ok ? r.text() : Promise.reject(r.status)); + content.textContent = text; + if (status) status.textContent = ''; + } catch(e) { + content.textContent = 'Could not load changelog: ' + e; + if (status) status.textContent = 'error'; + } +}); + function renderSettingsAbout() { const el = $('s-about-backends'); if (!el) return; @@ -180,6 +197,8 @@ $('s-import-voices-file')?.addEventListener('change', async function () { const VAD_THRESHOLD = 0.02; // raised to ignore background noise const VAD_MIN_REC_MS = 800; // wait 800ms before VAD starts checking (avoids click/noise at start) const VAD_SILENCE_MS = 1000; + const INTERRUPT_THRESHOLD = 0.04; // higher than VAD to avoid echo triggering interruption + const INTERRUPT_HOLD_MS = 350; // speech must persist this long to interrupt // Whisper hallucinations on silence/noise — discard these from the live preview const HALLUCINATION_RE = /^(reich|danke\s*(schön)?|vielen\s*dank|thank\s*you|thanks|you|copyright|abonnieren|untertitel|zарегистрируйтесь)[.!?,\s]*$/i; const origPlaceholder = textInput?.placeholder || ''; @@ -191,6 +210,10 @@ $('s-import-voices-file')?.addEventListener('change', async function () { const audioQueue = []; let audioQueuePlaying = false; let audioQueueDrainCb = null; + let interruptCtx = null; + let interruptRafId = null; + let interruptStream = null; + let interruptSpeechStart = 0; let vadHadSpeech = false; // true once RMS crossed threshold during this recording let vadLastVoiceMs = 0; // last timestamp speech was detected (for preview gate) let cancelNextBlob = false; // set by VAD when no speech was detected → skip STT @@ -391,6 +414,62 @@ $('s-import-voices-file')?.addEventListener('change', async function () { audioQueue.length = 0; audioQueuePlaying = false; audioQueueDrainCb = null; + stopInterruptMonitor(); + } + + function stopInterruptMonitor() { + cancelAnimationFrame(interruptRafId); interruptRafId = null; + if (interruptCtx) { try { interruptCtx.close(); } catch(_){} interruptCtx = null; } + if (interruptStream) { interruptStream.getTracks().forEach(t => t.stop()); interruptStream = null; } + interruptSpeechStart = 0; + } + + async function startInterruptMonitor() { + if (interruptCtx || !navigator.mediaDevices?.getUserMedia) return; + try { + interruptStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + interruptCtx = new (window.AudioContext || window.webkitAudioContext)(); + await interruptCtx.resume(); // may be suspended when created outside a user gesture + const src = interruptCtx.createMediaStreamSource(interruptStream); + const analyser = interruptCtx.createAnalyser(); + analyser.fftSize = 256; + src.connect(analyser); + const buf = new Float32Array(analyser.fftSize); + + function tick() { + // Stop monitoring once nothing is playing and queue is empty + if (!audioQueuePlaying && !audioQueue.length && !convCurrentAudio) { + stopInterruptMonitor(); + return; + } + analyser.getFloatTimeDomainData(buf); + let rms = 0; + for (const s of buf) rms += s * s; + rms = Math.sqrt(rms / buf.length); + + if (rms > INTERRUPT_THRESHOLD) { + if (!interruptSpeechStart) interruptSpeechStart = Date.now(); + if (Date.now() - interruptSpeechStart >= INTERRUPT_HOLD_MS) { + // User is talking — interrupt the AI. + // Force-release isProcessing so startRecording's guard doesn't block us. + stopInterruptMonitor(); + clearAudio(); + autoMicGeneration++; + isProcessing = false; + micBtn.classList.remove('processing'); + micIcon.className = 'mdi mdi-microphone'; + if (sendBtn) sendBtn.disabled = false; + if (textInput) textInput.disabled = false; + startRecording().catch(() => {}); + return; + } + } else { + interruptSpeechStart = 0; + } + interruptRafId = requestAnimationFrame(tick); + } + interruptRafId = requestAnimationFrame(tick); + } catch (_) { stopInterruptMonitor(); } } function playNextAudio() { @@ -421,6 +500,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () { el.addEventListener('ended', () => { URL.revokeObjectURL(item.url); playNextAudio(); }); el.play().catch(() => { URL.revokeObjectURL(item.url); playNextAudio(); }); if (micStatus) micStatus.textContent = 'Speaking…'; + startInterruptMonitor(); } function enqueueAudio(b64, mime, text) { @@ -435,8 +515,9 @@ $('s-import-voices-file')?.addEventListener('change', async function () { // ── Chunked Whisper preview (fallback for browsers without SpeechRecognition) ── async function transcribeForPreview() { if (previewTranscribing || !recChunks.length) return; - // Only transcribe if speech was actually detected in this recording - if (!vadHadSpeech || Date.now() - vadLastVoiceMs > 5000) return; + // When VAD is on, gate on detected speech to avoid transcribing silence. + // When VAD is off the user controls recording manually — always transcribe. + if (vadToggle?.checked && (!vadHadSpeech || Date.now() - vadLastVoiceMs > 5000)) return; previewTranscribing = true; try { const mime = (mediaRecorder && mediaRecorder.mimeType) || 'audio/webm'; @@ -479,6 +560,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () { async function startRecording() { if (isProcessing) return; + stopInterruptMonitor(); // release mic stream before opening a recording stream if (!navigator.mediaDevices?.getUserMedia) { toast('Microphone unavailable — browser requires a secure context (HTTPS or localhost). ' + 'Access the app via http://localhost:7890 or enable it in chrome://flags/#unsafely-treat-insecure-origin-as-secure', 'error', 8000); @@ -522,8 +604,9 @@ $('s-import-voices-file')?.addEventListener('change', async function () { const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' }); processBlob(blob); }; - // timeslice=1500: ondataavailable fires every 1.5 s → faster interim Whisper preview - mediaRecorder.start(1500); + // timeslice: 750ms when VAD is off (manual recording) for faster Whisper preview; + // 1500ms with VAD on (chunks are gated anyway, smaller slices waste CPU). + mediaRecorder.start(vadToggle?.checked ? 1500 : 750); micBtn.classList.add('recording'); micIcon.className = 'mdi mdi-stop'; if (micStatus) micStatus.textContent = 'Recording…'; @@ -743,6 +826,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () { micIcon.className = 'mdi mdi-microphone'; if (sendBtn) sendBtn.disabled = false; if (textInput) { textInput.disabled = false; textInput.value = ''; } + if (audioQueuePlaying || audioQueue.length || convCurrentAudio) startInterruptMonitor(); } } @@ -843,6 +927,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () { micIcon.className = 'mdi mdi-microphone'; if (sendBtn) sendBtn.disabled = false; if (textInput) textInput.disabled = false; + if (audioQueuePlaying || audioQueue.length || convCurrentAudio) startInterruptMonitor(); } } diff --git a/static/js/generation.js b/static/js/generation.js index 2bc9843..1befe84 100644 --- a/static/js/generation.js +++ b/static/js/generation.js @@ -391,3 +391,8 @@ $('refine-restore-btn')?.addEventListener('click', () => { toast('Original transcription restored', 'success'); }); +// Update style-support warning when user types in style field +$('preview-style-instruction')?.addEventListener('input', () => { + if (typeof updateBackendHelp === 'function') updateBackendHelp(); +}); + diff --git a/static/js/integrations.js b/static/js/integrations.js index a3745ee..4ace5b4 100644 --- a/static/js/integrations.js +++ b/static/js/integrations.js @@ -168,6 +168,10 @@ curl -s "${proxyV1}/audio/speech" \\ } }`; + if ($('snippet-hotkey')) $('snippet-hotkey').textContent = +`pip install pynput sounddevice soundfile pyperclip requests +python hotkey_daemon.py --server ${proxyBase}`; + if ($('snippet-speak')) $('snippet-speak').textContent = `# Bind your client ID to a voice once (persisted in Settings) curl -X PUT ${proxyBase}/speak/bindings/my-script \\ diff --git a/static/js/rehearser.js b/static/js/rehearser.js new file mode 100644 index 0000000..4125b97 --- /dev/null +++ b/static/js/rehearser.js @@ -0,0 +1,444 @@ +// ── Script Rehearser ────────────────────────────────────────────────────── + +// ── State ──────────────────────────────────────────────────────────────── + +const rehState = { + lines: [], // [{speaker, text}] + cast: {}, // {SPEAKER: {voice:'...' | 'me', color:'#...'}} + lineIndex: 0, + clips: [], // [{lineIndex, speaker, type:'tts'|'me'|'skip', blob?}] + voices: [], // available TTS voices + recStream: null, + recAudioCtx: null, + recAnalyser: null, + recSourceNode: null, + recGainNode: null, + recDestStream: null, + recMeterRaf: null, + recWaveRing: null, + mediaRec: null, + recChunks: [], + recTimer: null, + recSecs: 0, + phase: 1, +}; +window.rehState = rehState; + +const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec']; + +// ── Script parsing ──────────────────────────────────────────────────────── + +function parseScript(text) { + const lines = text.split('\n'); + const result = []; + let currentSpeaker = null; + let dialogBuffer = []; + + function flush() { + if (currentSpeaker && dialogBuffer.length) { + const t = dialogBuffer.join(' ').trim(); + if (t) result.push({ speaker: currentSpeaker, text: t }); + } + dialogBuffer = []; + } + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + // Format 1: "CHARACTER: dialog text" + const colonMatch = line.match(/^([A-Z][A-Z0-9 _\-]{0,39}):\s+(.+)$/); + if (colonMatch) { + flush(); + currentSpeaker = colonMatch[1].trim(); + dialogBuffer = [colonMatch[2].trim()]; + continue; + } + + // Format 2: ALL-CAPS name on its own line (screenplay) + if (/^[A-Z][A-Z0-9 _\-]{0,39}$/.test(line) && line.length >= 2) { + flush(); + currentSpeaker = line; + continue; + } + + // Continuation of dialog + if (currentSpeaker) dialogBuffer.push(line); + } + flush(); + return result; +} + +function detectCharacters(lines) { + const speakers = [...new Set(lines.map(l => l.speaker))]; + const cast = {}; + speakers.forEach((sp, i) => { + cast[sp] = { voice: 'me', color: SPEAKER_COLORS[i % SPEAKER_COLORS.length] }; + }); + return cast; +} + +// ── Phase navigation ────────────────────────────────────────────────────── + +function showPhase(n) { + rehState.phase = n; + for (let i = 1; i <= 4; i++) { + const el = $('reh-phase-' + i); + if (el) el.hidden = i !== n; + } +} + +// ── Phase 1: Script input ───────────────────────────────────────────────── + +$('reh-file-input')?.addEventListener('change', function () { + const f = this.files?.[0]; + if (!f) return; + const r = new FileReader(); + r.onload = e => { $('reh-script-text').value = e.target.result; }; + r.readAsText(f); + this.value = ''; +}); + +$('reh-parse-btn')?.addEventListener('click', () => { + const text = $('reh-script-text')?.value.trim(); + if (!text) { toast('Paste or upload a script first', 'error'); return; } + rehState.lines = parseScript(text); + if (!rehState.lines.length) { toast('No dialog lines found. Check format: "CHARACTER: text" or screenplay.', 'error'); return; } + rehState.cast = detectCharacters(rehState.lines); + renderCastList(); + showPhase(2); + // Populate backend select + refreshRehBackends(); +}); + +// ── Phase 2: Cast assignment ────────────────────────────────────────────── + +function renderCastList() { + const list = $('reh-cast-list'); + if (!list) return; + const speakers = Object.keys(rehState.cast); + const lineCount = sp => rehState.lines.filter(l => l.speaker === sp).length; + list.innerHTML = speakers.map(sp => { + const c = rehState.cast[sp]; + const color = c.color; + return `
+ + ${escHtml(sp)} + ${lineCount(sp)} line${lineCount(sp) !== 1 ? 's' : ''} + + +
`; + }).join(''); + + list.querySelectorAll('.reh-me-check').forEach(cb => { + cb.addEventListener('change', function () { + const sp = this.dataset.speaker; + const row = this.closest('.reh-cast-row'); + const sel = row.querySelector('.reh-voice-sel'); + rehState.cast[sp].voice = this.checked ? 'me' : (sel.value || ''); + if (sel) sel.style.display = this.checked ? 'none' : ''; + }); + }); + list.querySelectorAll('.reh-voice-sel').forEach(sel => { + sel.addEventListener('change', function () { + rehState.cast[this.dataset.speaker].voice = this.value; + }); + }); +} + +async function refreshRehBackends() { + const sel = $('reh-backend-select'); + if (!sel) return; + const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : []; + sel.innerHTML = backends.length + ? backends.map(b => ``).join('') + : ''; +} + +$('reh-fetch-voices-btn')?.addEventListener('click', async () => { + const backend = $('reh-backend-select')?.value; + if (!backend) { toast('Select a backend first', 'error'); return; } + $('reh-fetch-voices-btn').disabled = true; + try { + const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); + rehState.voices = Array.isArray(rawVoices) ? rawVoices.map(v => typeof v === 'string' ? v : (v.id || v.name || String(v))) : []; + renderCastList(); + toast('Fetched ' + rehState.voices.length + ' voices', 'success'); + } catch(e) { toast('Fetch failed: ' + e.message, 'error'); } + finally { $('reh-fetch-voices-btn').disabled = false; } +}); + +$('reh-back-1-btn')?.addEventListener('click', () => showPhase(1)); + +$('reh-start-btn')?.addEventListener('click', () => { + const backend = $('reh-backend-select')?.value; + if (!backend) { toast('Select a backend first', 'error'); return; } + rehState.lineIndex = 0; + rehState.clips = []; + rehState.backend = backend; + showPhase(3); + renderCurrentLine(); +}); + +// ── Phase 3: Rehearsal loop ─────────────────────────────────────────────── + +function updateProgress() { + const total = rehState.lines.length; + const cur = rehState.lineIndex; + const pct = total ? (cur / total) * 100 : 0; + const bar = $('reh-progress-bar'); + if (bar) bar.style.width = pct + '%'; + const lbl = $('reh-progress-label'); + if (lbl) lbl.textContent = `Line ${cur + 1} / ${total}`; +} + +function renderCurrentLine() { + const lines = rehState.lines; + const i = rehState.lineIndex; + if (i >= lines.length) { finishRehearsal(); return; } + const line = lines[i]; + const cast = rehState.cast[line.speaker] || { voice: 'me', color: '#89b4fa' }; + const isMe = cast.voice === 'me'; + + $('reh-line-speaker').textContent = line.speaker; + $('reh-line-speaker').style.color = cast.color; + $('reh-line-text').textContent = line.text; + + const recPanel = $('reh-record-panel'); + const ttsPanel = $('reh-tts-panel'); + if (recPanel) recPanel.hidden = !isMe; + if (ttsPanel) ttsPanel.hidden = isMe; + + updateProgress(); + + if (!isMe) { + synthesizeLine(line.text, cast.voice, rehState.backend); + } else { + // Reset recording UI + const recPreview = $('reh-rec-preview'); + if (recPreview) { recPreview.style.display = 'none'; recPreview.src = ''; } + const confirmRow = $('reh-rec-confirm-row'); + if (confirmRow) confirmRow.hidden = true; + if ($('reh-rec-start')) $('reh-rec-start').disabled = false; + if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true; + if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00'; + } +} + +async function synthesizeLine(text, voice, backend) { + const ttsAudio = $('reh-tts-audio'); + const ttsStatus = $('reh-tts-status'); + if (ttsStatus) ttsStatus.textContent = 'Synthesizing…'; + if (ttsAudio) { ttsAudio.pause(); ttsAudio.src = ''; } + try { + const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend); + const url = URL.createObjectURL(blob); + if (ttsAudio) { ttsAudio.src = url; ttsAudio.style.display = ''; ttsAudio.play().catch(() => {}); } + if (ttsStatus) ttsStatus.textContent = 'Playing…'; + rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex].speaker, type: 'tts', blob }); + ttsAudio?.addEventListener('ended', () => { if (ttsStatus) ttsStatus.textContent = 'Done'; }, { once: true }); + } catch(e) { + if (ttsStatus) ttsStatus.textContent = 'Synthesis failed: ' + e.message; + toast('TTS failed: ' + e.message, 'error'); + } +} + +$('reh-tts-next')?.addEventListener('click', () => { advanceLine(1); }); +$('reh-tts-replay')?.addEventListener('click', () => { $('reh-tts-audio')?.play().catch(() => {}); }); +$('reh-prev-line')?.addEventListener('click', () => { advanceLine(-1); }); +$('reh-skip-line')?.addEventListener('click', () => { + rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex]?.speaker, type: 'skip' }); + advanceLine(1); +}); +$('reh-stop-reh')?.addEventListener('click', () => { + stopRehMic(); + finishRehearsal(); +}); + +function advanceLine(delta) { + rehState.lineIndex = Math.max(0, Math.min(rehState.lines.length - 1, rehState.lineIndex + delta)); + renderCurrentLine(); +} + +function finishRehearsal() { + stopRehMic(); + renderSummary(); + showPhase(4); +} + +// ── Mic recording (my lines) ────────────────────────────────────────────── + +function rehRenderMeter(level = 0, db = -Infinity, clipped = false) { + const meter = $('reh-mic-meter'); + if (!meter) return; + if (!meter.children.length) { + for (let i = 0; i < 18; i++) { const b = document.createElement('div'); b.className = 'bar'; meter.appendChild(b); } + } + const active = Math.round(Math.max(0, Math.min(1, level)) * meter.children.length); + [...meter.children].forEach((bar, i) => { + bar.className = 'bar'; + bar.style.height = (7 + Math.min(i, active) * 1.55) + 'px'; + if (i < active) { bar.classList.add('on'); if (db > -12 && i > 11) bar.classList.add('hot'); if (clipped && i > 14) bar.classList.add('clip'); } + }); + const el = $('reh-db-readout'); + if (el) el.textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB'; +} + +function rehStartMeter() { + if (!rehState.recAnalyser) return; + if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf); + const data = new Float32Array(rehState.recAnalyser.fftSize); + const canvas = $('reh-live-wave'); + const RING = 300, ADD = 10; + rehState.recWaveRing = new Float32Array(RING); + const tick = () => { + rehState.recAnalyser.getFloatTimeDomainData(data); + let sum = 0, peak = 0; + for (const s of data) { sum += s * s; peak = Math.max(peak, Math.abs(s)); } + const rms = Math.sqrt(sum / data.length); + const db = rms > 0 ? 20 * Math.log10(rms) : -Infinity; + rehRenderMeter((db + 60) / 60, db, peak > 0.98); + if (canvas && rehState.recWaveRing) { + const ring = rehState.recWaveRing; + ring.copyWithin(0, ADD); + for (let i = 0; i < ADD; i++) ring[RING - ADD + i] = data[Math.floor(i * data.length / ADD)]; + const ctx = canvas.getContext('2d'), w = canvas.width, h = canvas.height; + ctx.clearRect(0, 0, w, h); + ctx.beginPath(); + ctx.strokeStyle = peak > 0.98 ? '#f38ba8' : db > -12 ? '#f9e2af' : '#a6e3a1'; + ctx.lineWidth = 1.5; + const mid = h / 2; + for (let i = 0; i < RING; i++) { const x = (i / RING) * w, y = mid - ring[i] * mid * 0.85; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } + ctx.stroke(); + } + rehState.recMeterRaf = requestAnimationFrame(tick); + }; + tick(); +} + +async function startRehMic() { + if (rehState.recDestStream) return; + const AudioCtx = window.AudioContext || window.webkitAudioContext; + rehState.recStream = await requestMicrophoneStream({ raw: true }); + if (AudioCtx) { + rehState.recAudioCtx = new AudioCtx(); + rehState.recSourceNode = rehState.recAudioCtx.createMediaStreamSource(rehState.recStream); + rehState.recGainNode = rehState.recAudioCtx.createGain(); + rehState.recAnalyser = rehState.recAudioCtx.createAnalyser(); + rehState.recAnalyser.fftSize = 1024; + const dest = rehState.recAudioCtx.createMediaStreamDestination(); + rehState.recSourceNode.connect(rehState.recGainNode); + rehState.recGainNode.connect(rehState.recAnalyser); + rehState.recGainNode.connect(dest); + rehState.recDestStream = dest.stream; + rehStartMeter(); + } else { + rehState.recDestStream = rehState.recStream; + } +} + +function stopRehMic() { + if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf); + rehState.recMeterRaf = null; + [rehState.recSourceNode, rehState.recGainNode, rehState.recAnalyser].forEach(n => { try { if(n) n.disconnect(); } catch(e){} }); + if (rehState.recStream) rehState.recStream.getTracks().forEach(t => t.stop()); + if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t => t.stop()); + if (rehState.recAudioCtx) rehState.recAudioCtx.close().catch(() => {}); + Object.assign(rehState, { recStream: null, recDestStream: null, recSourceNode: null, recGainNode: null, recAnalyser: null, recAudioCtx: null, recWaveRing: null }); + rehRenderMeter(); + const wc = $('reh-live-wave'); if (wc) wc.getContext('2d').clearRect(0, 0, wc.width, wc.height); +} + +$('reh-rec-start')?.addEventListener('click', async () => { + try { + await startRehMic(); + rehState.recChunks = []; + rehState.recSecs = 0; + if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00'; + if ($('reh-rec-start')) $('reh-rec-start').disabled = true; + if ($('reh-rec-stop')) $('reh-rec-stop').disabled = false; + if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true; + rehState.recTimer = setInterval(() => { + rehState.recSecs++; + if ($('reh-rec-time')) $('reh-rec-time').textContent = Math.floor(rehState.recSecs / 60) + ':' + String(rehState.recSecs % 60).padStart(2, '0'); + }, 1000); + rehState.mediaRec = new MediaRecorder(rehState.recDestStream || rehState.recStream, { audioBitsPerSecond: 256000 }); + rehState.mediaRec.ondataavailable = e => { if (e.data.size) rehState.recChunks.push(e.data); }; + rehState.mediaRec.onstop = () => { + clearInterval(rehState.recTimer); + if ($('reh-rec-start')) $('reh-rec-start').disabled = false; + if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true; + const blob = new Blob(rehState.recChunks, { type: rehState.mediaRec.mimeType || 'audio/webm' }); + const url = URL.createObjectURL(blob); + const preview = $('reh-rec-preview'); + if (preview) { preview.src = url; preview.style.display = ''; } + if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = false; + rehState.lastRecBlob = blob; + }; + rehState.mediaRec.start(100); + } catch(e) { stopRehMic(); toast(await microphoneErrorMessage(e), 'error'); } +}); + +$('reh-rec-stop')?.addEventListener('click', () => { + if (rehState.mediaRec?.state !== 'inactive') rehState.mediaRec.stop(); +}); + +$('reh-rec-keep')?.addEventListener('click', () => { + const blob = rehState.lastRecBlob; + if (blob) { + rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex]?.speaker, type: 'me', blob }); + } + stopRehMic(); + advanceLine(1); +}); + +$('reh-rec-redo')?.addEventListener('click', () => { + stopRehMic(); + renderCurrentLine(); // re-show the same line +}); + +// ── Phase 4: Summary ────────────────────────────────────────────────────── + +function renderSummary() { + const list = $('reh-summary-list'); + if (!list) return; + if (!rehState.clips.length) { list.innerHTML = '

No clips recorded in this session.

'; return; } + list.innerHTML = rehState.clips.map((clip, idx) => { + const line = rehState.lines[clip.lineIndex] || { text: '—', speaker: clip.speaker }; + const typeLabel = clip.type === 'me' ? '🎤 Recorded' : clip.type === 'tts' ? '🔊 Synthesized' : '⏭ Skipped'; + const color = rehState.cast[clip.speaker]?.color || '#89b4fa'; + const audioHtml = clip.blob + ? `` + : ''; + return `
+ +
+
${escHtml(clip.speaker)} ${typeLabel}
+
${escHtml(line.text)}
+ ${audioHtml} +
+ ${clip.blob ? `` : ''} +
`; + }).join(''); +} + +$('reh-new-session-btn')?.addEventListener('click', () => { + stopRehMic(); + rehState.lines = []; rehState.cast = {}; rehState.clips = []; rehState.lineIndex = 0; + if ($('reh-script-text')) $('reh-script-text').value = ''; + showPhase(1); +}); + +$('reh-resume-btn')?.addEventListener('click', () => { + showPhase(3); + renderCurrentLine(); +}); + +// Init +rehRenderMeter(); diff --git a/static/js/settings.js b/static/js/settings.js index 9820f2b..7d8e087 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -64,6 +64,23 @@ function updateBackendHelp() { const b = backendById($('tts-backend-select')?.value || ''); const help = $('tts-backend-help'); if (help) help.innerHTML = backendHelpHtml(b); + // Dynamic style-support badge + warning in Try It Out + const styleSupport = $('preview-style-support'); + const styleWarn = $('preview-style-warn'); + const styleInput = $('preview-style-instruction'); + if (b && styleSupport) { + if (b.style_aware) { + styleSupport.textContent = 'style-aware ✓'; + styleSupport.style.cssText = 'font-size:11px;display:inline-block;background:rgba(166,227,161,.2);color:var(--green);border-radius:4px;padding:1px 6px;margin-left:4px'; + } else { + styleSupport.textContent = 'weak style'; + styleSupport.style.cssText = 'font-size:11px;display:inline-block;background:rgba(249,226,175,.2);color:var(--yellow);border-radius:4px;padding:1px 6px;margin-left:4px'; + } + } + if (styleWarn) { + const hasInstruct = (styleInput?.value || '').trim().length > 0; + styleWarn.style.display = (b && !b.style_aware && hasInstruct) ? 'block' : 'none'; + } const sttB = backendById($('stt-tts-backend-select')?.value || ''); const sttHelp = $('stt-tts-backend-help'); if (sttHelp) sttHelp.innerHTML = backendHelpHtml(sttB); diff --git a/static/js/voice-inspector.js b/static/js/voice-inspector.js index 92dcd17..7caa15f 100644 --- a/static/js/voice-inspector.js +++ b/static/js/voice-inspector.js @@ -26,11 +26,13 @@ function selectVoice(wrap) { wrap.classList.remove('vr-selected'); _selectedVoiceWrap = null; inspector.innerHTML = '

Pick a voice on the left
to edit it here

'; + if (typeof window.onMobileInspectorClose === 'function') window.onMobileInspectorClose(); return; } _selectedVoiceWrap = wrap; wrap.classList.add('vr-selected'); + if (typeof window.onMobileInspectorOpen === 'function') window.onMobileInspectorOpen(); const voiceId = wrap.dataset.id || ''; const color = wrap.dataset.color || '#9575CD'; @@ -63,6 +65,10 @@ function selectVoice(wrap) { `` ).join(''); + const inspBenchText = typeof fmtBenchmark === 'function' ? fmtBenchmark(v) : '-'; + const inspBenchTitle = typeof benchmarkTitle === 'function' ? benchmarkTitle(v) : ''; + const inspBenchCls = typeof benchmarkClass === 'function' ? benchmarkClass(v) : ''; + const LANGS = ['EN','DE','IT','ES','FR','PT','NL','PL','ZH','JA','KO','AR','RU','TR','HI','SV','DA','FI','NB','HU','CS','RO','UK']; // Gender maps — used in template AND in event handlers @@ -99,6 +105,7 @@ function selectVoice(wrap) { ${escHtml(genderLabelHtml)} ${isClone ? 'Clone' : 'Design'} + ${inspBenchText !== '-' ? ` ${escHtml(inspBenchText)}` : ''} ${makeStars(rating)} ${rating}/5 @@ -115,6 +122,11 @@ function selectVoice(wrap) {
`; + // Inject mobile back button after HTML is set (inspector DOM recreated above) + if (window.innerWidth <= 767 && typeof window.onMobileInspectorOpen === 'function') { + window.onMobileInspectorOpen(); + } + const saveSlot = inspector.querySelector('.insp-actions-save'); const activeSlot = inspector.querySelector('.insp-actions-active'); const deleteSlot = inspector.querySelector('.insp-actions-delete'); diff --git a/static/js/voice-library.js b/static/js/voice-library.js index d44af70..2f0689f 100644 --- a/static/js/voice-library.js +++ b/static/js/voice-library.js @@ -97,14 +97,37 @@ function setSort(field) { renderVoiceList(); } +function toggleSortDir() { + _sortDir *= -1; + syncSortHeaders(); + renderVoiceList(); +} + function syncSortHeaders() { document.querySelectorAll('.vl-header [data-sort]').forEach(el => { el.classList.remove('sort-asc', 'sort-desc'); if (el.dataset.sort === _sortField) el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc'); }); + const sel = document.getElementById('voice-sort-field'); + if (sel && sel.value !== _sortField) sel.value = _sortField; + const dirBtn = document.getElementById('voice-sort-dir'); + if (dirBtn) { + const icon = dirBtn.querySelector('.mdi'); + if (icon) icon.className = _sortDir === 1 ? 'mdi mdi-arrow-up' : 'mdi mdi-arrow-down'; + dirBtn.title = _sortDir === 1 ? 'Ascending — click to reverse' : 'Descending — click to reverse'; + } } +// Wire sort direction button via addEventListener (more reliable than inline onclick +// since the button is injected into the DOM after script execution). +document.addEventListener('click', e => { + if (e.target.closest('#voice-sort-dir')) toggleSortDir(); +}); +document.addEventListener('change', e => { + if (e.target.id === 'voice-sort-field') setSort(e.target.value); +}); + const FLAG_LANGUAGE_CANDIDATES = { GB:['EN'], US:['EN'], AU:['EN'], NZ:['EN'], IE:['EN'], ZA:['EN'], NG:['EN'], KE:['EN'], GH:['EN'], JM:['EN'], TT:['EN'], CA:['EN','FR'], IN:['EN','HI'], SG:['EN','ZH'], PH:['EN','FIL'], MT:['EN','MT'], @@ -1637,6 +1660,7 @@ function makeVoiceRow(v) {
${isClone ? 'Clone' : 'Design'} ${gender ? `${genderMap[gender]||'?'} ${genderLabel[gender]||''}` : ''} + ${benchText !== '-' ? ` ${escHtml(benchText)}` : ''}
diff --git a/static/loader.js b/static/loader.js index 6a0cd3c..f7f95b7 100644 --- a/static/loader.js +++ b/static/loader.js @@ -2,7 +2,7 @@ 'use strict'; const SECTIONS = [ - 's-voices', 's-clone', 's-design', 's-studio', 's-tryout', + 's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation', ]; @@ -103,6 +103,7 @@ '/static/js/tts-preview.js', '/static/js/benchmark.js', '/static/js/stt.js', + '/static/js/rehearser.js', ]); // D — init (needs everything above to be defined) diff --git a/static/nav.js b/static/nav.js index 79af4d0..cf2ed18 100644 --- a/static/nav.js +++ b/static/nav.js @@ -18,7 +18,7 @@ llms: 's-llms' }; - const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation']; + const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation']; function runSideEffects(name) { if ((name === 'source' || name === 'save') && typeof initCloneSampleText === 'function') initCloneSampleText(); diff --git a/static/sections/s-connect.html b/static/sections/s-connect.html index be98bf1..e85bca4 100644 --- a/static/sections/s-connect.html +++ b/static/sections/s-connect.html @@ -2,72 +2,14 @@

Connect Your Apps

-

Copy ready-made configuration snippets for SillyTavern, Open WebUI, Home Assistant, MCP agents, and more.

+

Ready-made snippets for SillyTavern, Open WebUI, Home Assistant, Claude Code MCP, /speak REST, hotkey daemon, and more.

- - -
-

MCP Server

-

Expose voice tools to Claude Code, Cursor, and other MCP-aware agents via Streamable HTTP (JSON-RPC 2.0). Tools: speak, transcribe, list_captures, list_profiles.

-
-
- - http://localhost:7890/mcp -
-
- - claude mcp add voice-creator --transport http --url http://localhost:7890/mcp --header "X-Voice-Creator-Client-Id: claude-code" -
-
- -
{"mcpServers":{"voice-creator":{"url":"http://localhost:7890/mcp","headers":{"X-Voice-Creator-Client-Id":"my-agent"}}}}
-
-
-
- - -
-

/speak REST endpoint

-

Generate audio from any app or script without routing rules.

-
-
- -
curl -X POST http://localhost:7890/speak \
-  -H "Content-Type: application/json" \
-  -H "X-Voice-Creator-Client-Id: my-script" \
-  -d '{"text":"Hello world","voice":"EN_F_Anna"}' \
-  --output speech.wav
-
-
- -
curl -X PUT http://localhost:7890/speak/bindings/my-script \
-  -H "Content-Type: application/json" \
-  -d '{"voice":"EN_F_Anna"}'
-
-
-
- - -
-

Global hotkey daemon

-

Push-to-talk transcription that types the result into any focused window on the host machine.

-
-
- -
pip install pynput sounddevice soundfile pyperclip requests
-python hotkey_daemon.py --server http://localhost:7890
- Hold Ctrl+Shift+Space to record, release to transcribe and type. Linux: install xdotool for direct key injection. -
-
-
- -

Use voices in other apps

-

The editor creates and manages the voice files. External apps should connect to the Creator proxy or a reachable TTS backend, then use one of the active voice names.

+

External apps connect to the Creator proxy or a reachable TTS backend and use any active voice name.

@@ -76,6 +18,8 @@ python hotkey_daemon.py --server http://localhost:7890
+ +

SillyTavern

Use an OpenAI-compatible TTS provider. Paste one active voice into the voice field, or paste the comma-separated list where SillyTavern accepts custom voices.

@@ -106,32 +50,44 @@ python hotkey_daemon.py --server http://localhost:7890
+ +
-

MCP — Native built-in server

-

The app ships a built-in MCP server at /mcp (JSON-RPC 2.0, Streamable HTTP). No external script or extra packages needed. Tools: speak, transcribe, list_captures, list_profiles.

+

MCP — built-in server

+

The app ships a built-in MCP server at /mcp (Streamable HTTP, JSON-RPC 2.0). No extra packages needed. Tools: speak, transcribe, list_captures, list_profiles.

-
+
+

/speak — direct REST

-

POST text to /speak from any script, agent, or app. Voice resolves from explicit param → per-client binding → default voice. Optional persona LLM rewrite.

+

POST text to /speak from any script or agent. Voice resolves from param → per-client binding → default. Optional persona LLM rewrite.

+ +
+

Global hotkey daemon

+

Push-to-talk transcription on the host. Hold Ctrl+Shift+Space to record, release to transcribe and type into any window. Linux: needs xdotool.

+
+ +
+

Streaming TTS

Use this when the target app can play audio progressively. For routed streaming, keep response format WAV and avoid before/after route sounds, otherwise the proxy must buffer before playback.

+

Important after voice changes

After enabling, hiding, adding, renaming, cropping, or normalising voices, restart the Qwen3-TTS container so its engine scans the updated active_voices folder. Then refresh the model or voice list in the target app.

-

Virtual VoiceDesign voices are different: they use saved prompt presets through this app's proxy and do not need a WAV export or TTS-container rescan. They do need the faster-qwen3-tts-voicedesign container reachable from Settings.

+

Virtual VoiceDesign voices use saved prompt presets through this app's proxy and do not need a WAV export or TTS-container rescan.

diff --git a/static/sections/s-rehearser.html b/static/sections/s-rehearser.html new file mode 100644 index 0000000..5f4c3f6 --- /dev/null +++ b/static/sections/s-rehearser.html @@ -0,0 +1,124 @@ +
+ +
+

Script Rehearser

+

Upload a script, assign voices to characters, then synthesize the other parts while you record your own.

+
+
+ +
+ + +
+
+

Step 1 — Paste or upload your script

+

Supported formats: CHARACTER: dialog on one line, or screenplay style (character name on its own ALL-CAPS line followed by dialog). Each speaker is detected automatically.

+
+ +
+ +

Format hints:
CHARACTER: text
• All-caps name on own line + dialog below
• Lines starting with # are stage directions (skipped)

+
+
+
+ +
+
+
+ + + + + + + + + + +
diff --git a/static/sections/s-settings.html b/static/sections/s-settings.html index 540299d..755b4ac 100644 --- a/static/sections/s-settings.html +++ b/static/sections/s-settings.html @@ -530,6 +530,15 @@

MCP server, REST /speak endpoint, and global hotkey daemon are documented under Connect Apps.

+
+
+ + Changelog + + +

+            
+
diff --git a/static/sections/s-tryout.html b/static/sections/s-tryout.html index 2a84403..f3666e3 100644 --- a/static/sections/s-tryout.html +++ b/static/sections/s-tryout.html @@ -8,23 +8,17 @@
+ +
-

TTS generation playground

-

Pick any reachable TTS backend, fetch its voices, then synthesize text. WAV/NVIDIA clone backends preserve reference identity; instruction-control backends follow style better.

-
-
-

Generate speech

-

Select a backend, fetch its voice list, then synthesize any text with optional style instruction.

-
+

Voice & backend

+
- -
-
- Checking available TTS backends... +
- +
@@ -40,8 +34,11 @@
-

After changing active voices, restart the TTS container so the engine reads the updated voice folder.

- + + +
+

Text to synthesize

- - +
-
- - - This is sent as instruct. Voice Clone/Base and Streaming are fastest; CustomVoice and Voice Design are style-aware. +
+ + +
-
-