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 `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 `Pick a voice on the left
to edit it here
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.
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"}}}}
- 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"}'
- 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.
- 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.
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
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.
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.
-
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.
Push-to-talk transcription on the host. Hold Ctrl+Shift+Space to record, release to transcribe and type into any window. Linux: needs xdotool.
+
+ 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.
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.
Upload a script, assign voices to characters, then synthesize the other parts while you record your own.
+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)
Assign a TTS voice to each character, or mark a character as Me — those lines will be recorded from your microphone during rehearsal.
+ +Your recorded lines and all synthesized lines from this session.
+ +MCP server, REST /speak endpoint, and global hotkey daemon are documented under Connect Apps.
Pick any reachable TTS backend, fetch its voices, then synthesize text. WAV/NVIDIA clone backends preserve reference identity; instruction-control backends follow style better.
-Select a backend, fetch its voice list, then synthesize any text with optional style instruction.
-After changing active voices, restart the TTS container so the engine reads the updated voice folder.
-instruct. Voice Clone/Base and Streaming are fastest; CustomVoice and Voice Design are style-aware.
+