diff --git a/.gitignore b/.gitignore index 275cf1c..98ea295 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ hidden_voices/ # Local settings and generated scratch data settings.json voice_design_presets.json +hotkey_daemon.py # Personal documentation (not part of the public repo) APP_ERKLÄRUNG.md diff --git a/README.md b/README.md index 7e85ec4..c0fd2c5 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ A browser-based voice management studio running in Docker. Clone real voices, de --- +## User Manual + +For a step-by-step app tutorial, see [docs/USER_MANUAL.md](docs/USER_MANUAL.md). + +--- + ## What it does ``` diff --git a/core/config.py b/core/config.py index 0a96d60..24ce7b4 100644 --- a/core/config.py +++ b/core/config.py @@ -54,6 +54,8 @@ _TTS_STABILITY_BY_BACKEND_DEFAULT = { "nvidia_flow": {}, "kokoro": {}, "vibevoice": {}, + "xtts": {}, + "fishspeech": {}, } _TTS_PAYLOAD_CORE_KEYS = {"model", "input", "voice", "response_format", "instruct", "language"} diff --git a/core/constants.py b/core/constants.py index 5e418a0..54b5ae9 100644 --- a/core/constants.py +++ b/core/constants.py @@ -33,12 +33,12 @@ _NVIDIA_ASR_DEFAULT = os.environ.get("NVIDIA_PARAKEET_ASR_URL", "http://host.doc _NVIDIA_CLONE_DEFAULT = os.environ.get("NVIDIA_TTS_CLONE_URL", "http://host.docker.internal:8093") _NVIDIA_ZEROSHOT_DEFAULT = os.environ.get("NVIDIA_ZEROSHOT_TTS_URL", _NVIDIA_CLONE_DEFAULT) _NVIDIA_FLOW_DEFAULT = os.environ.get("NVIDIA_FLOW_TTS_URL", "http://host.docker.internal:8094") -_FASTER_WHISPER_DEFAULT = os.environ.get("FASTER_WHISPER_URL", "http://host.docker.internal:8000") +_FASTER_WHISPER_DEFAULT = os.environ.get("FASTER_WHISPER_URL", "http://host.docker.internal:8010") _WHISPER_CPP_DEFAULT = os.environ.get("WHISPER_CPP_URL", "http://host.docker.internal:8085") _GROQ_STT_ENDPOINT = "https://api.groq.com/openai/v1" _KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://host.docker.internal:8880/v1") _VIBEVOICE_DEFAULT = os.environ.get("VIBEVOICE_URL", "http://192.168.178.8:8027") -_XTTS_DEFAULT = os.environ.get("XTTS_URL", "http://host.docker.internal:8024") +_XTTS_DEFAULT = os.environ.get("XTTS_URL", "http://host.docker.internal:8005") _FISHSPEECH_DEFAULT = os.environ.get("FISHSPEECH_URL", "http://host.docker.internal:38080") _TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts") _TTS_CONTAINERS_RAW = os.environ.get("TTS_CONTAINER_NAMES", "") # comma-separated override diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md new file mode 100644 index 0000000..191e72e --- /dev/null +++ b/docs/USER_MANUAL.md @@ -0,0 +1,714 @@ +# Voice Creator User Manual + +This manual explains how to use **TTS Voice Creator - Clone, Design, Deploy** from the browser UI. It is written for day-to-day use: setting up engines, building a voice library, cloning and designing voices, rehearsing scripts, running a live voice agent, and connecting other apps. + +## Table Of Contents + +1. Quick start +2. How the app is organized +3. Engines and backend setup +4. Voice library +5. Clone a Voice +6. Design a Voice +7. Get Voices Online +8. Try It Out +9. Script Rehearser +10. Conversation Playground +11. Benchmark +12. App Routing +13. Connect Apps and APIs +14. Settings +15. Troubleshooting +16. Recommended workflows + +## 1. Quick Start + +1. Start the Docker stack. +2. Open the app in a browser, usually: + + ```text + http://localhost:7890 + ``` + + or, from another machine: + + ```text + http://:7890 + ``` + +3. Go to **Setup -> Engines** and confirm your STT, TTS, and LLM containers are detected. +4. Go to **Setup -> Engines -> Text to Speech** and click **Use as TTS** or the backend-specific button for the TTS engine you want. +5. Go to **Speech to Text** or **Settings -> Connections** and set your STT endpoint. +6. Go to **Try It Out**, fetch voices, select a voice, type text, and synthesize. +7. Build your voice library with **Clone a Voice**, **Design a Voice**, or **Get Voices Online**. +8. Use **Script Rehearser** for scripts or **Conversation** for a live voice agent. + +## 2. How The App Is Organized + +The left sidebar is the main map of the app. + +### Voices + +- **My Voices**: Browse and manage all local voices. +- **All voices**: Every visible voice. +- **Cloned**: Voices created from reference audio. +- **Designed**: Voices created from a voice-design prompt. +- **Favorites**: Voices marked as favorites. +- **Hidden**: Voices hidden from normal pickers. +- **Library tools**: Bulk library maintenance. + +### Voice Creation + +- **Clone a Voice**: Create a voice from an audio file, recording, URL, or extracted clip. +- **Design a Voice**: Create a new voice from a text description. +- **Get Voices Online**: Search and import online voice clips. +- **Try It Out**: Test TTS, STT, effects, voice style, and persona rewriting. + +### Script And Agent Workflows + +- **Script Rehearser**: Import scripts, cast characters, assign voices, and rehearse scenes. +- **Conversation**: Talk to a live AI voice agent using STT -> LLM -> TTS. +- **Benchmark**: Measure speed and quality across voices and engines. + +### Setup And Integration + +- **Engines**: See local Docker engines and connect them to the app. +- **App Routing**: Route incoming TTS requests from other apps to voices/backends. +- **Connect Apps**: Copy API examples for other tools. +- **Settings**: Configure URLs, storage, keys, logs, backup, and defaults. + +## 3. Engines And Backend Setup + +The app is a control surface. Most speech generation and recognition happens in separate local or cloud engines. + +### Engine Types + +| Type | Used For | Examples | +| --- | --- | --- | +| Text to Speech | Generate spoken audio | Qwen3-TTS, Fish-Speech, XTTS, VibeVoice, NVIDIA Magpie | +| Speech to Text | Transcribe microphone or uploaded audio | faster-whisper, Parakeet, WhisperX, Nemotron ASR | +| Language Model | Rewrite text, cast characters, run conversation agent | LiteLLM, llama.cpp, Ollama, vLLM | + +### Setup From Engines Page + +1. Open **Setup -> Engines**. +2. Choose **Text to Speech**, **Speech to Text**, or **Language Models**. +3. Look at the cards at the top. These are detected local Docker containers. +4. If a card matches the engine you want, click its action button: + - **Use as TTS** + - **Use as Voice Design** + - **Use as Custom Voice** + - **Use as Fish-Speech** + - **Use as VibeVoice** +5. If the engine is not detected, click **Add Custom** and enter the URL manually. +6. Use **Refresh** after starting or restarting containers. + +### Common Local Ports + +These are common local endpoints. Your stack may differ. + +| Engine | Typical URL | Notes | +| --- | --- | --- | +| Qwen3 Voice Clone/Base | `http://host.docker.internal:8020` | Best default for cloned voices | +| Qwen3 Voice Design | `http://host.docker.internal:8021` | Required for design prompts | +| Qwen3 Custom Voice | `http://host.docker.internal:8022` | Custom voice/tone-style backend | +| Qwen3 Streaming | `http://host.docker.internal:8023` | Lower latency streaming | +| faster-whisper | `http://host.docker.internal:8010` | STT transcription | +| NVIDIA Parakeet ASR | `http://host.docker.internal:8090` or app card URL | Fast STT | +| NVIDIA Magpie TTS | `http://host.docker.internal:8091` | Fixed-speaker TTS | +| VibeVoice | `http://host.docker.internal:8027` | Simple TTS backend | +| XTTS v2 | `http://host.docker.internal:8005` | OpenAI-compatible voice clone endpoint | +| LiteLLM | `http://host.docker.internal:14000/v1` | OpenAI-compatible LLM gateway | +| llama-swap | `http://host.docker.internal:28080/v1` | LLM routing gateway | +| Ollama | `http://host.docker.internal:11434/v1` | Local LLM endpoint | + +### Which Engines Make Sense Where? + +| App Area | Good Engines | +| --- | --- | +| Clone a Voice | Qwen3 Voice Clone/Base, XTTS, Fish-Speech if configured | +| Design a Voice | Qwen3 Voice Design, Qwen3 Custom Voice | +| Try It Out | Any available TTS backend | +| Script Rehearser | Qwen3 Voice Clone/Base for cast voices; Voice Design for generating new cast voices; LLM for auto-casting | +| Conversation | Fast STT plus fast LLM plus fast/streaming TTS | +| Benchmark | Any backend you want to compare | + +## 4. Voice Library + +The voice library is the center of the app. A voice usually consists of: + +- A reference audio file, usually WAV. +- A text transcript/reference prompt. +- Metadata such as language, gender, tags, rating, notes, and persona. +- Optional picture/avatar. + +### Browsing Voices + +1. Open **My Voices**. +2. Use sidebar filters such as **All voices**, **Cloned**, **Designed**, **Favorites**, and **Hidden**. +3. Use the search field to filter by name, tags, language, or metadata. +4. Click a voice to inspect and edit it. + +### Voice Visibility + +- Visible voices appear in normal pickers. +- Hidden voices are kept in the library but filtered out from most assignment/search fields. +- Use **Hidden** in the sidebar when you need to review or restore them. + +### Metadata + +For each voice you can usually manage: + +- Display name +- Language +- Gender +- Tags +- Favorite flag +- Hidden/visible state +- Notes +- Character persona +- Reference text +- Picture/avatar + +### Character Persona + +A persona is a writing-style prompt attached to a voice. When enabled, the app sends your text through an LLM first so the generated speech sounds more like that character. + +Example persona: + +```text +Warm but dry-humored older engineer. Speaks clearly, uses practical metaphors, and avoids dramatic language. +``` + +Use persona rewrite when: + +- You want an assistant to stay in character. +- A script line needs a more natural phrasing for a voice. +- You want a voice to preserve a speaking style across different apps. + +Avoid persona rewrite when: + +- You need exact text output. +- You are generating legal, medical, or quoted text. +- You are benchmarking raw TTS speed. + +## 5. Clone A Voice + +Use **Clone a Voice** when you have an audio sample and want to create a reusable voice. + +### Good Source Audio + +Best results come from audio that is: + +- 5 to 30 seconds long. +- One speaker only. +- Clean and close-mic recorded. +- No music or loud background noise. +- Emotionally representative of the voice you want. +- Not heavily compressed or distorted. + +### Workflow + +1. Open **Clone a Voice**. +2. Choose a source: + - Upload file + - Record microphone + - Paste URL + - Import from another source if available +3. Use the waveform editor to trim to the cleanest section. +4. Transcribe the clip or enter the reference text manually. +5. Name the voice. +6. Set language, tags, and notes. +7. Click **Save to Voice Library**. +8. Go to **Try It Out** and test it. + +### Tips + +- Short clean clips beat long noisy clips. +- Trim out breaths, music, applause, and other speakers. +- Keep the transcript close to the actual words in the audio. +- Use consistent naming, for example `EN_F_Name_01` or `DE_M_Name_01`. + +## 6. Design A Voice + +Use **Design a Voice** when you want a new voice from a description rather than a recorded speaker. + +### Workflow + +1. Open **Design a Voice**. +2. Make sure the Voice Design backend is connected, usually Qwen3 Voice Design on port `8021`. +3. Write a voice prompt. +4. Generate a sample. +5. Adjust the prompt and regenerate until it fits. +6. Save the result to the voice library. + +### Prompt Template + +```text +A calm German male narrator in his late 40s, warm baritone, precise articulation, subtle smile, low breathiness, natural documentary pacing. +``` + +Useful prompt parts: + +- Language/accent +- Age range +- Gender presentation +- Pitch/timbre +- Energy level +- Emotion +- Speaking pace +- Use case, such as narrator, villain, teacher, assistant + +### Tone And Style Control + +Some Qwen3-style backends support a style or instruction field per request. Use that for line-level emotion, such as: + +```text +gentle reassurance, slower pace, quiet confidence +``` + +In Script Rehearser, tone can be set per line or character when the backend supports it. + +## 7. Get Voices Online + +Use **Get Voices Online** to find and import external voice clips. + +General workflow: + +1. Search or paste a source URL. +2. Preview the clip. +3. Import it into the library. +4. Clean up metadata. +5. Test it in **Try It Out**. + +Always review licensing and consent before using a voice outside personal testing. + +## 8. Try It Out + +**Try It Out** is the fastest place to test voices and backends. + +### Basic TTS Test + +1. Select a TTS backend. +2. Click **Fetch voices**. +3. Pick a voice from the searchable picker. +4. Type text. +5. Optional: add style instruction. +6. Click synthesize/play. + +### Searchable Voice Picker + +The voice picker lets you: + +- Search by voice name. +- See avatar/picture when available. +- Select quickly without scrolling through hundreds of voices. + +If hidden voices appear where they should not, check the voice visibility in **My Voices -> Hidden**. + +### Style Instruction + +Use style instruction for delivery, not content. + +Good: + +```text +quiet, serious, slightly slower, emotionally restrained +``` + +Less good: + +```text +change the sentence to be about pirates +``` + +### STT To TTS + +Some Try It Out views let you: + +1. Record or upload audio. +2. Transcribe it with STT. +3. Optionally clean/refine it with an LLM. +4. Re-synthesize it with another voice. + +## 9. Script Rehearser + +Script Rehearser is for importing a screenplay or dialogue, assigning voices to characters, and rehearsing selected parts. + +### Main Phases + +| Phase | Purpose | +| --- | --- | +| Library | Import, browse, or load scripts | +| Cast | Match characters to voices | +| Stage | Play, rehearse, select, hide, ignore, and edit lines | +| Summary | Review script/cast status | +| Import/Export | Move `.reh` files in and out | + +### Import A Script + +Options include: + +- Paste text manually. +- Upload `.txt`, `.md`, `.pdf`, `.fdx`, or `.osf` when supported. +- Use **Browse scripts** to browse IMSDb scripts. +- Paste an IMSDb or Scripts.com URL. +- Import a `.reh` project. + +### Browse Scripts + +1. Open **Script Rehearser -> Library**. +2. Click **Browse scripts**. +3. Search titles in the modal. +4. Switch between thumbnail and list view. +5. Click a title to fetch it. +6. Click **Parse & cast** after it loads. + +You can also paste a direct Scripts.com URL, for example: + +```text +https://www.scripts.com/script/the_pianist_72/2 +``` + +### Cast Characters + +1. Parse the script. +2. Open **Cast**. +3. Choose a TTS backend. +4. Click **Fetch voices**. +5. Assign voices manually, or use LLM-assisted matching: + - Match from local library + - Match from online voices when available + - Design new voices using Voice Design +6. Use the searchable voice picker to find voices quickly. +7. Add speaking-style notes for characters when needed. + +### Stage Playback + +The Stage lets you: + +- Play the whole script. +- Play from a selected line. +- Click a character/avatar play button for an individual line. +- Skip descriptions. +- Pre-synthesize lines for faster playback. +- Re-synthesize stale lines when tone/style changed. +- Save/export the rehearsal project. + +### Select Mode + +Use **Select** in Stage when you want to work with only part of a script. + +- Click **Select** to enter selection mode. +- Click lines to select or deselect them. +- Shift-click another line to select a range. +- Click **Set range** to make playback use that selected range. +- Use **Ignore**, **Un-ignore**, **Hide**, or **Delete** for bulk cleanup. +- Click **Done** when finished. + +For actors, common navigation is usually by: + +- Page number +- Scene heading +- Cue line +- Character entrance +- Act/scene number for stage plays + +For this app, the cleanest practical workflow is: scene heading plus selected range. Example: "Rehearse from EXT. HARBOR - NIGHT through Jack's next line." + +### Hide vs Ignore vs Delete + +| Action | Meaning | +| --- | --- | +| Ignore | Keep visible, but skip during playback/synthesis | +| Hide | Remove from normal view, but keep recoverable | +| Delete | Permanently remove from the project | + +## 10. Conversation Playground + +Conversation Playground is a live voice-agent pipeline: + +```text +Microphone -> STT -> LLM -> TTS -> Spoken answer +``` + +### Basic Setup + +1. Pick an STT backend. +2. Enter or select an LLM endpoint. +3. Fetch/select an LLM model. +4. Pick a TTS backend. +5. Fetch/select a voice. +6. Set the system prompt. +7. Click the mic button to start live listening. + +### Hot Mic Mode + +The mic button is now an on/off toggle. + +- Click once: mic is active and keeps listening. +- Click again: mic turns off. +- While active, silence does not submit empty turns. +- Speech interrupts LLM/TTS processing and starts a new recording. +- The app automatically enables Auto-stop, Hands-free, and Live agent for this mode. + +### Live Agent Options + +| Option | What It Does | +| --- | --- | +| Auto-stop | Sends speech after silence is detected | +| Hands-free | Re-arms the mic after the agent finishes | +| Live agent | Shorter silence and interruption delays | + +### Interrupting The Agent + +With hot mic or Live agent active, you can speak while the agent is: + +- Generating LLM text +- Synthesizing speech +- Playing speech + +The current turn is aborted and the app begins recording the new utterance. + +### Good Conversation Settings + +For fast live turn-taking: + +- Use a fast STT backend such as Parakeet or faster-whisper on GPU. +- Use a small/fast LLM model for conversation. +- Use a low-latency TTS backend. +- Prefer streaming TTS if available. +- Keep the system prompt short. + +## 11. Benchmark + +Use **Benchmark** to compare voice/backend performance. + +Typical use: + +1. Select backend. +2. Select test text or sentence set. +3. Run one voice or a batch. +4. Compare latency, duration, and reliability. + +Useful metrics: + +- Time to first audio +- Total synthesis time +- Realtime factor +- Failures/timeouts +- Subjective quality notes + +## 12. App Routing + +App Routing lets other tools call one TTS endpoint while this app decides which voice/backend to use. + +Use routing when: + +- Multiple apps share the same TTS server. +- You want app-specific default voices. +- You want language-specific voices. +- You want before/after sounds for certain apps. + +Typical workflow: + +1. Open **App Routing**. +2. Add a client/app name. +3. Pick a default voice. +4. Choose backend if needed. +5. Add language or voice-name rules. +6. Test with the generated curl example. + +## 13. Connect Apps And APIs + +Open **Connect Apps** for ready-to-copy examples. + +Common API patterns: + +### Generate Speech + +```bash +curl -X POST http://localhost:7890/speak \ + -H "Content-Type: application/json" \ + -d '{"text":"Hello from Voice Creator","voice":"EN_F_Example"}' \ + --output out.wav +``` + +### Bind A Client To A Voice + +```bash +curl -X PUT http://localhost:7890/speak/bindings/my-app \ + -H "Content-Type: application/json" \ + -d '{"voice":"EN_F_Example"}' +``` + +### Use A Bound Voice + +```bash +curl -X POST http://localhost:7890/speak \ + -H "Content-Type: application/json" \ + -H "X-Voice-Creator-Client-Id: my-app" \ + -d '{"text":"Use the voice bound to this app."}' \ + --output out.wav +``` + +### MCP + +The app includes an MCP endpoint. Use **Connect Apps** or **Settings -> About** for current connection examples. + +## 14. Settings + +Settings are grouped by task. + +| Area | Use It For | +| --- | --- | +| General | App defaults and behavior | +| Connections | Backend URLs for TTS, STT, LLM, NVIDIA, VibeVoice, etc. | +| Playback | Audio playback defaults | +| Captures | Saved recordings and generated clips | +| Payloads | Advanced request payload settings | +| Storage | Voice directories and file handling | +| API Keys | Cloud provider keys | +| Backup | Export/import app settings and library metadata | +| Logs | Inspect recent app logs | +| About | Version, examples, MCP info | + +## 15. Troubleshooting + +### Browser Microphone Does Not Work + +Browsers require a secure context for microphone access. + +Try one of these: + +- Open the app at `http://localhost:7890` on the same machine. +- Use HTTPS. +- In Chrome, allow the local network origin as secure if needed. +- Check browser microphone permissions. + +### Voice Picker Shows Old Data + +- Click **Fetch voices** again. +- Hard refresh the page. +- If the app is installed as a PWA, close and reopen it. +- Check that the selected TTS backend is correct. + +### Hidden Voices Appear + +- Confirm the voice is actually marked hidden in **My Voices -> Hidden**. +- Fetch voices again. +- Hard refresh if the browser cached an older script. + +### Script Browser Looks Wrong + +- Switch between thumbnail and list view. +- Refresh the catalogue. +- Hard refresh the page if the modal layout looks stale. +- Paste the direct IMSDb/Scripts.com URL instead of browsing. + +### Conversation Hears Nothing + +- Confirm STT backend is running. +- Confirm browser mic permissions. +- Check **Speech to Text** engine status. +- Try the fake/manual text input to confirm LLM/TTS works. +- Turn hot mic off and on again. + +### Conversation Sends Empty Turns + +Hot mic mode should ignore silence. If empty turns still happen: + +- Make sure the latest page is loaded with a hard refresh. +- Use **Auto-stop** and **Live agent** together. +- Increase microphone input level if real speech is too quiet. +- Reduce background noise. + +### Agent Is Too Slow + +Latency is the sum of STT, LLM, and TTS. + +Improve it by: + +- Using a faster STT engine. +- Using a smaller LLM model. +- Using streaming TTS. +- Keeping prompts short. +- Preloading models where possible. +- Avoiding huge conversation history. + +### TTS Fails For A Voice + +- Confirm the voice exists in the active voice library. +- Confirm the backend supports that voice type. +- Fetch voices again. +- Check logs in **Settings -> Logs**. +- Try another backend to isolate whether the issue is voice data or engine support. + +### Service Worker Or Cache Issues + +The app can be installed as a PWA and may cache UI files. + +If UI changes do not appear: + +1. Hard refresh the page. +2. Open DevTools and disable cache while reloading. +3. Unregister the service worker for the app origin. +4. Reopen the app. + +## 16. Recommended Workflows + +### Build A Clean Voice Library + +1. Import or clone voices. +2. Normalize names and tags. +3. Mark favorites. +4. Hide experiments and failed voices. +5. Add persona prompts only to voices you want to use as characters. +6. Benchmark your best voices. + +### Create A Script Rehearsal + +1. Import or browse a script. +2. Parse and inspect characters. +3. Use Cast to assign voices. +4. Use LLM matching for a first pass. +5. Manually fix important roles. +6. Open Stage. +7. Select a scene/range. +8. Pre-synthesize the selected range. +9. Rehearse with playback or Train mode. +10. Save/export `.reh`. + +### Build A Fast Voice Agent + +1. Use Parakeet or faster-whisper for STT. +2. Use LiteLLM/llama-swap/Ollama with a small model. +3. Use a fast TTS backend. +4. Select a voice in Conversation. +5. Keep the system prompt short. +6. Turn on hot mic. +7. Interrupt naturally by speaking. + +### Design Voices For A Cast + +1. Parse script. +2. Open Cast. +3. Let the LLM describe character souls/briefs. +4. Use Voice Design for missing archetypes. +5. Save successful designs to the library. +6. Hide rejected experiments. +7. Assign final voices to the cast. + +## Glossary + +| Term | Meaning | +| --- | --- | +| Backend | A separate engine/server that performs TTS, STT, or LLM work | +| Voice clone | A reusable voice made from reference audio | +| Voice design | A voice generated from a text description | +| Persona | A character-writing prompt attached to a voice | +| STT | Speech to Text, transcription | +| TTS | Text to Speech, synthesis | +| LLM | Language model used for rewriting, matching, and conversation | +| Hot mic | Always-listening conversation mode toggled by the mic icon | +| `.reh` | Script Rehearser project export | diff --git a/routes/docker.py b/routes/docker.py index d6bdfc7..d25e6ff 100644 --- a/routes/docker.py +++ b/routes/docker.py @@ -14,13 +14,26 @@ from core.validation import _normalize_service_url router = APIRouter() _LOCAL_CONTAINER_DEFS: list[dict] = [ - {"name": "faster-qwen3-tts-voiceclone", "label": "Qwen3 TTS · Voice Clone", "role": "tts", "port": 8020, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "WAV voice cloning. Scans active_voices at startup — restart after adding or editing voices."}, - {"name": "faster-qwen3-tts-voicedesign", "label": "Qwen3 TTS · Voice Design", "role": "tts", "port": 8021, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Instruction-based voice design. Describe a voice in words — no WAV needed."}, - {"name": "faster-qwen3-tts-customvoice", "label": "Qwen3 TTS · Custom Voice", "role": "tts", "port": 8022, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Style control over configured premium speakers such as Ryan, Vivian, and Serena."}, - {"name": "faster-qwen3-tts-streaming", "label": "Qwen3 TTS · Streaming", "role": "tts", "port": 8023, "stack": "faster-qwen3-tts-dgx-spark-voice-design", "image": "martinb78/qwen3-tts-streaming-dgx-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Low-latency progressive WAV streaming for voice clone voices."}, - {"name": "parakeet-asr", "label": "NVIDIA Parakeet ASR", "role": "stt", "port": 8090, "stack": "nvidia-speech-gateway", "image": "parakeet-tdt-v3-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet GPU-accelerated speech recognition on port 8090."}, - {"name": "magpie-tts", "label": "NVIDIA Magpie TTS", "role": "tts", "port": 8091, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/magpie-tts-multilingual:latest","repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA neural TTS. GPU-accelerated, high-quality multilingual synthesis."}, - {"name": "parakeet-rnnt-nim", "label": "NVIDIA Parakeet RNNT NIM", "role": "stt", "port": 8092, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/parakeet-1b-rnnt-multilingual:latest","repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet RNNT NIM multilingual ASR on port 8092."}, + {"name": "litellm", "label": "LiteLLM Gateway", "role": "llm", "port": 14000, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "ghcr.io/berriai/litellm:main-latest", "repo": "https://github.com/BerriAI/litellm", "description": "OpenAI-compatible LLM gateway. Use for character analysis, casting, and text refinement."}, + {"name": "llama.cpp", "label": "llama.cpp Server", "role": "llm", "port": 19000, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "ghcr.io/ggml-org/llama.cpp:server", "repo": "https://github.com/ggml-org/llama.cpp", "description": "OpenAI-compatible local LLM server when launched with its server API."}, + {"name": "ollama", "label": "Ollama", "role": "llm", "port": 11434, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "ollama/ollama", "repo": "https://ollama.com", "description": "Local LLM runtime. The app uses its OpenAI-compatible /v1 endpoint."}, + {"name": "llama-swap", "label": "llama-swap", "role": "llm", "port": 28080, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "ghcr.io/mostlygeek/llama-swap:cuda", "repo": "https://github.com/mostlygeek/llama-swap", "description": "Model swapping proxy for local LLMs. Useful as the default Rehearser LLM endpoint."}, + {"name": "llama-qwen35-4b", "label": "Qwen3.5 4B LLM", "role": "llm", "port": 19001, "stack": "dgx-spark_lite-llm_llama-swap_vllm_llama-cpp_ollama", "image": "local", "repo": "", "description": "Dedicated Qwen LLM container. Use if it exposes an OpenAI-compatible /v1 API."}, + {"name": "faster-qwen3-tts-voiceclone", "label": "Qwen3 TTS · Voice Clone", "role": "tts", "port": 8020, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "WAV voice cloning. Scans active_voices at startup; restart after adding or editing voices."}, + {"name": "faster-qwen3-tts-voicedesign", "label": "Qwen3 TTS · Voice Design", "role": "tts", "port": 8021, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Instruction-based voice design. Best Qwen path for tone, style, and character prompts."}, + {"name": "faster-qwen3-tts-customvoice", "label": "Qwen3 TTS · Custom Voice", "role": "tts", "port": 8022, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:v4", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "CustomVoice speakers with per-request instruct overrides for tone/style."}, + {"name": "faster-qwen3-tts-streaming", "label": "Qwen3 TTS · Streaming", "role": "tts", "port": 8023, "stack": "faster-qwen3-tts-dgx-spark", "image": "martinb78/faster-qwen3-tts-dgx-spark:streaming", "repo": "https://github.com/mARTin-B78/dgx-spark-faster-qwen3-tts", "description": "Low-latency progressive WAV streaming for voice clone voices."}, + {"name": "faster-whisper", "label": "faster-whisper", "role": "stt", "port": 8010, "stack": "faster-whisper-dgx-spark", "image": "local", "repo": "https://github.com/SYSTRAN/faster-whisper", "description": "Fast local Whisper transcription endpoint."}, + {"name": "fish-speech-api", "label": "Fish-Speech API", "role": "tts", "port": 38080, "stack": "fish-speech-dgx-spark", "image": "local", "repo": "https://github.com/fishaudio/fish-speech", "description": "Tone-aware cloning backend. Recommended for Rehearser character voices when you need both identity and emotion."}, + {"name": "nemotron-asr-streaming", "label": "Nemotron ASR Streaming", "role": "stt", "port": 8006, "stack": "nemotron-asr-streaming", "image": "local", "repo": "https://github.com/pipecat-ai/nemotron-january-2026", "description": "Streaming ASR service. Use as a custom STT endpoint if it exposes OpenAI transcription or /transcribe."}, + {"name": "parakeet-stt-multi-9011", "label": "NVIDIA Parakeet STT", "role": "stt", "port": 8007, "stack": "nvidia-parakeet-voice-recognition", "image": "local", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "Parakeet STT container on port 8007."}, + {"name": "nvidia-magpie-tts", "label": "NVIDIA Magpie TTS", "role": "tts", "port": 8091, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/magpie-tts-multilingual:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA neural TTS. Fixed speaker voices, fast multilingual synthesis."}, + {"name": "nvidia-parakeet-asr", "label": "NVIDIA Parakeet Router ASR", "role": "stt", "port": 8090, "stack": "nvidia-speech-gateway", "image": "parakeet-tdt-v3-spark:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "NVIDIA Parakeet speech router endpoint."}, + {"name": "nvidia-parakeet-rnnt-nim", "label": "NVIDIA Parakeet RNNT NIM", "role": "stt", "port": 8092, "stack": "nvidia-speech-gateway", "image": "nvcr.io/nim/nvidia/parakeet-1b-rnnt-multilingual:latest", "repo": "https://github.com/mARTin-B78/dgx-spark-parakeet-asr", "description": "Parakeet RNNT NIM multilingual ASR on port 8092."}, + {"name": "vibevoice-msft-tts", "label": "VibeVoice TTS", "role": "tts", "port": 8027, "stack": "vibevoice", "image": "local", "repo": "", "description": "VibeVoice REST service. Simple text-to-speech backend."}, + {"name": "voicebox", "label": "Voicebox", "role": "llm", "port": 17493, "stack": "voicebox", "image": "local", "repo": "", "description": "Companion voice app/control plane. Not used as a normal TTS engine unless wrapped as an API."}, + {"name": "whisperx-blackwell", "label": "WhisperX Blackwell", "role": "stt", "port": 8003, "stack": "whisperx-blackwell", "image": "local", "repo": "https://github.com/m-bain/whisperX", "description": "WhisperX transcription/alignment service. Use as custom STT if its API is OpenAI-compatible or /transcribe."}, + {"name": "xtts-dgx-spark", "label": "XTTS v2", "role": "tts", "port": 8005, "stack": "xtts_v2", "image": "local", "repo": "https://github.com/daswer123/xtts-api-server", "description": "XTTS v2 OpenAI-compatible speaker endpoint."}, ] diff --git a/routes/sources.py b/routes/sources.py index 58bd775..f179acc 100644 --- a/routes/sources.py +++ b/routes/sources.py @@ -817,6 +817,39 @@ def _fetch_imsdb_script(raw_url: str, headers: dict) -> tuple[requests.Response, return last, title + +def _extract_scripts_com_text(html: str) -> str: + """Extract the visible screenplay section from a Scripts.com page.""" + body = html + start = re.search(r']*>\s*(?:<[^>]+>\s*)*(?:]+>[^<]*Prev[^<]*|«|«|‹)', body, re.I) + if start: + body = body[start.end():] + stop = re.search(r'Rate this script:|Submitted by|#### Discuss this', body, re.I) + if stop: + body = body[:stop.start()] + body = re.sub(r'', '\n', body, flags=re.I) + body = re.sub(r'', '\n', body, flags=re.I) + body = re.sub(r'', '\n', body, flags=re.I) + body = re.sub(r']+href="https?://www\.(?:definitions|abbreviations)\.com/[^>]+>(.*?)', r'\1', body, flags=re.I | re.S) + body = re.sub(r'<[^>]+>', ' ', body) + text = unescape(body) + text = text.replace('\xa0', ' ') + lines = [] + for raw in text.splitlines(): + line = re.sub(r'\s+', ' ', raw).strip() + if not line: + if lines and lines[-1] != '': + lines.append('') + continue + if (line in {'|', '»', 'Prev', 'Next', '« Prev | Next »'} + or line.startswith('Next »') + or re.fullmatch(r'[|«»\s]*(?:Prev)?\s*\|?\s*Next\s*»?', line, re.I)): + continue + lines.append(line) + text = '\n'.join(lines).strip() + text = re.sub(r'\n{3,}', '\n\n', text) + return text + def _extract_screenplay_text(html: str) -> str: """Extract and clean screenplay text from a page's
 block."""
     m = re.search(r"]*>([\s\S]+?)
", html, re.I) @@ -873,6 +906,11 @@ async def fetch_web_script(request: Request): content_type = resp.headers.get("content-type", "").lower() if "text/plain" in content_type: text = resp.text.strip() + elif "scripts.com" in host: + text = _extract_scripts_com_text(resp.text) + if not text: + raise HTTPException(422, "Could not find screenplay text on that Scripts.com page. Try a concrete /script/... page URL.") + title = title or _plain_title_from_html(resp.text) else: # HTML — try to extract
 screenplay block
         text = _extract_screenplay_text(resp.text)
@@ -885,6 +923,12 @@ async def fetch_web_script(request: Request):
     return _web_script_result(text, title, fetch_url, parsed)
 
 
+def _plain_title_from_html(html: str) -> str:
+    m = re.search(r"]*>([\s\S]+?)|]*>([\s\S]+?)", html, re.I)
+    if not m:
+        return ""
+    return re.sub(r"\s+", " ", unescape(re.sub(r"<[^>]+>", " ", m.group(1) or m.group(2) or ""))).strip()
+
 def _web_script_result(text, title, fetch_url, parsed):
     if not title:
         title = parsed.path.split("/")[-1].replace(".html", "").replace("-", " ").replace("_", " ").strip()
diff --git a/static/js/conversation.js b/static/js/conversation.js
index 6560241..76f068c 100644
--- a/static/js/conversation.js
+++ b/static/js/conversation.js
@@ -195,17 +195,25 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
   let liveInterimText = '';
   let speechRec = null;
   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_MIN_REC_MS = 650;   // wait 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
+  const liveAgentOn = () => liveAgentToggle ? liveAgentToggle.checked : true;
+  const vadSilenceMs = () => liveAgentOn() ? 650 : VAD_SILENCE_MS;
+  const interruptHoldMs = () => liveAgentOn() ? 120 : INTERRUPT_HOLD_MS;
   // 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 || '';
   const vadToggle       = $('conv-vad-toggle');
   const handsFreeToggle = $('conv-handsfree-toggle');
+  const liveAgentToggle = $('conv-live-agent-toggle');
   let previewTranscribing = false;
   let convCurrentAudio = null;
+  let convAbortCtrl = null;
+  let convTurnGeneration = 0;
+  let liveMicEnabled = false;
+  let cancelRecordingOnStop = false;
   let autoMicGeneration = 0;
   const audioQueue = [];
   let audioQueuePlaying = false;
@@ -295,14 +303,33 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
     if (!ttsVoiceSel || !ttsBkSel) return;
     const backend = ttsBkSel.value;
     if (!backend) return;
+    const prev = ttsVoiceSel.value;
     ttsFetchBtn.disabled = true;
     try {
       const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
-      ttsVoiceSel.innerHTML = rawVoices.length
-        ? rawVoices.map(v => { const id = backendVoiceId(v); return ``; }).join('')
-        : '';
+      let voices = Array.isArray(rawVoices) ? rawVoices : [];
+      if (typeof shouldFilterBackendVoices === 'function' && shouldFilterBackendVoices(backend)) {
+        const activeIds = await activeLibraryVoiceIds();
+        voices = voices.filter(v => activeIds.has(backendVoiceId(v)));
+      }
+      const ids = voices.map(backendVoiceId).filter(Boolean);
+      if (window.VoicePicker) {
+        VoicePicker.upgrade('conv-tts-voice-select');
+        VoicePicker.populate('conv-tts-voice-select', ids);
+        if (prev && ids.includes(prev)) VoicePicker.setValue('conv-tts-voice-select', prev);
+        else if (ids.length) VoicePicker.setValue('conv-tts-voice-select', ids[0]);
+      } else {
+        ttsVoiceSel.innerHTML = ids.length
+          ? ids.map(id => ``).join('')
+          : '';
+        if (prev && ids.includes(prev)) ttsVoiceSel.value = prev;
+      }
     } catch(e) {
       ttsVoiceSel.innerHTML = '';
+      if (window.VoicePicker) {
+        VoicePicker.upgrade('conv-tts-voice-select');
+        VoicePicker.populate('conv-tts-voice-select', []);
+      }
     } finally {
       ttsFetchBtn.disabled = false;
     }
@@ -414,12 +441,61 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
   // ── Audio queue — plays multi-chunk TTS responses sequentially ──────────────
   function clearAudio() {
     if (convCurrentAudio) { try { convCurrentAudio.pause(); } catch(_){} convCurrentAudio = null; }
+    audioQueue.forEach(item => { if (item?.url) try { URL.revokeObjectURL(item.url); } catch(_){} });
     audioQueue.length = 0;
     audioQueuePlaying = false;
     audioQueueDrainCb = null;
     stopInterruptMonitor();
   }
 
+  function agentBusy() {
+    return !!(isProcessing || audioQueuePlaying || audioQueue.length || convCurrentAudio || convAbortCtrl);
+  }
+
+  function isRecording() {
+    return !!(mediaRecorder && mediaRecorder.state === 'recording');
+  }
+
+  function setLiveMicUi(statusText) {
+    micBtn.classList.toggle('live', liveMicEnabled);
+    micBtn.title = liveMicEnabled ? 'Microphone is always listening - click to turn off' : 'Click to turn live microphone on';
+    if (!isRecording() && !isProcessing) micIcon.className = 'mdi mdi-microphone';
+    if (micStatus && statusText) micStatus.textContent = statusText;
+  }
+
+  function resetConversationInput(statusText = 'Ready') {
+    liveInterimText = '';
+    previewTranscribing = false;
+    isProcessing = false;
+    micBtn.classList.remove('processing', 'recording');
+    micIcon.className = 'mdi mdi-microphone';
+    setLiveMicUi();
+    if (sendBtn) sendBtn.disabled = false;
+    if (textInput) {
+      textInput.disabled = false;
+      textInput.readOnly = false;
+      textInput.classList.remove('listening');
+      textInput.placeholder = origPlaceholder;
+      textInput.value = '';
+    }
+    if (micStatus) micStatus.textContent = statusText;
+  }
+
+  function abortCurrentTurn() {
+    convTurnGeneration++;
+    if (convAbortCtrl) { try { convAbortCtrl.abort(); } catch(_){} }
+    convAbortCtrl = null;
+  }
+
+  async function interruptAgentAndListen() {
+    liveMicEnabled = true;
+    autoMicGeneration++;
+    abortCurrentTurn();
+    clearAudio();
+    resetConversationInput('Interrupted - listening...');
+    await startRecording();
+  }
+
   function stopInterruptMonitor() {
     cancelAnimationFrame(interruptRafId); interruptRafId = null;
     if (interruptCtx) { try { interruptCtx.close(); } catch(_){} interruptCtx = null; }
@@ -440,8 +516,9 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
       const buf = new Float32Array(analyser.fftSize);
 
       function tick() {
-        // Stop monitoring once nothing is playing and queue is empty
-        if (!audioQueuePlaying && !audioQueue.length && !convCurrentAudio) {
+        // Keep listening during live-agent processing too, not only while audio is playing.
+        const processingTurn = (liveAgentOn() || liveMicEnabled) && (isProcessing || !!convAbortCtrl);
+        if (!audioQueuePlaying && !audioQueue.length && !convCurrentAudio && !processingTurn) {
           stopInterruptMonitor();
           return;
         }
@@ -452,18 +529,9 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
 
         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(() => {});
+          if (Date.now() - interruptSpeechStart >= interruptHoldMs()) {
+            // User is talking - interrupt the AI and listen immediately.
+            interruptAgentAndListen().catch(() => {});
             return;
           }
         } else {
@@ -545,10 +613,11 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
 
   // ── Auto-restart mic after agent finishes speaking ───────────────────────
   function scheduleAutoMic() {
-    if (!handsFreeToggle?.checked) return;
+    if (!liveMicEnabled && !handsFreeToggle?.checked) return;
     const gen = ++autoMicGeneration;
     function tryStart() {
       if (gen !== autoMicGeneration || isProcessing) return;
+      if (!liveMicEnabled && !handsFreeToggle?.checked) return;
       startRecording().catch(() => {});
     }
     if (audioQueuePlaying || audioQueue.length > 0) {
@@ -593,15 +662,18 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
     };
     mediaRecorder.onstop = () => {
       stream.getTracks().forEach(t => t.stop());
-      if (cancelNextBlob) {
-        // VAD fired but no speech was detected — reset without calling STT
+      if (cancelNextBlob || cancelRecordingOnStop) {
+        // VAD fired with no speech, or the user turned the hot mic off.
         cancelNextBlob = false;
+        cancelRecordingOnStop = false;
         if (textInput) { textInput.readOnly = false; textInput.classList.remove('listening'); textInput.placeholder = origPlaceholder; textInput.value = ''; }
         if (sendBtn) sendBtn.disabled = false;
-        micBtn.classList.remove('processing');
+        micBtn.classList.remove('processing', 'recording');
         micIcon.className = 'mdi mdi-microphone';
-        if (micStatus) micStatus.textContent = 'Ready';
+        if (micStatus) micStatus.textContent = liveMicEnabled ? 'Listening…' : 'Ready';
         isProcessing = false;
+        setLiveMicUi();
+        if (liveMicEnabled) startRecording().catch(() => {});
         return;
       }
       const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
@@ -609,10 +681,11 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
     };
     // 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);
+    mediaRecorder.start(vadToggle?.checked ? (liveAgentOn() ? 900 : 1500) : 750);
     micBtn.classList.add('recording');
-    micIcon.className = 'mdi mdi-stop';
-    if (micStatus) micStatus.textContent = 'Recording…';
+    setLiveMicUi();
+    micIcon.className = liveMicEnabled ? 'mdi mdi-microphone' : 'mdi mdi-stop';
+    if (micStatus) micStatus.textContent = liveMicEnabled ? 'Listening…' : 'Recording…';
     startRecTimer();
 
     // Set input to listening mode (read-only; Whisper preview text will appear here)
@@ -674,15 +747,27 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
           const elapsed = Date.now() - recStart;
           const silence = Date.now() - vadLastVoice;
           if (elapsed > VAD_MIN_REC_MS) {
-            const remaining = VAD_SILENCE_MS - silence;
+            const silenceLimit = vadSilenceMs();
+            const remaining = silenceLimit - silence;
             if (micStatus) {
-              micStatus.textContent = remaining < VAD_SILENCE_MS * 0.7
-                ? `Sending in ${(Math.max(0, remaining) / 1000).toFixed(1)}s…`
-                : 'Recording…';
+              micStatus.textContent = liveMicEnabled && !vadHadSpeech
+                ? 'Listening…'
+                : remaining < silenceLimit * 0.7
+                  ? `Sending in ${(Math.max(0, remaining) / 1000).toFixed(1)}s…`
+                  : (liveMicEnabled ? 'Listening…' : 'Recording…');
             }
-            if (silence >= VAD_SILENCE_MS) {
+            if (silence >= silenceLimit) {
               if (!vadHadSpeech) {
-                // Silence the whole time — cancel without calling STT
+                if (liveMicEnabled) {
+                  // Hot mic: keep listening through silence without retaining silent chunks forever.
+                  recChunks = [];
+                  recStart = Date.now();
+                  vadLastVoice = Date.now();
+                  if (micStatus) micStatus.textContent = 'Listening…';
+                  vadRafId = requestAnimationFrame(vadTick);
+                  return;
+                }
+                // Silence the whole time - cancel without calling STT
                 cancelNextBlob = true;
                 if (micStatus) micStatus.textContent = 'Ready';
               }
@@ -757,8 +842,13 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
     form.append('system_prompt', systemPrompt?.value.trim() || 'You are a helpful voice assistant.');
     form.append('history', JSON.stringify(conversationHistory.slice(-20)));
 
+    const turnGen = ++convTurnGeneration;
+    if (convAbortCtrl) { try { convAbortCtrl.abort(); } catch(_){} }
+    convAbortCtrl = new AbortController();
+    if (liveMicEnabled || liveAgentOn()) startInterruptMonitor();
+
     try {
-      const resp = await fetch('/api/conversation/turn', { method: 'POST', body: form });
+      const resp = await fetch('/api/conversation/turn', { method: 'POST', body: form, signal: convAbortCtrl.signal });
       if (!resp.ok) { let _d=''; try { const _j=await resp.json(); _d=JSON.stringify(_j.detail||_j); } catch(_){} throw new Error('Server error ' + resp.status + (_d ? ': ' + _d : '')); }
       const reader = resp.body.getReader();
       const dec = new TextDecoder();
@@ -766,11 +856,13 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
 
       while (true) {
         const { done, value } = await reader.read();
+        if (turnGen !== convTurnGeneration) return;
         if (done) break;
         buf += dec.decode(value, { stream: true });
         const lines = buf.split('\n');
         buf = lines.pop();
         for (const line of lines) {
+          if (turnGen !== convTurnGeneration) return;
           if (!line.startsWith('data:')) continue;
           let evt;
           try { evt = JSON.parse(line.slice(5).trim()); } catch(_) { continue; }
@@ -815,21 +907,28 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
         }
       }
     } catch(e) {
-      clearAudio();
-      const wrap = assistantBubble.closest('.conv-bubble-wrap');
-      if (wrap) wrap.remove();
-      addErrorBubble(e.message);
-      addHistoryItem(turnN, Date.now() - t0, false);
-      if (micStatus) micStatus.textContent = 'Error — ready';
+      if (e?.name !== 'AbortError' && turnGen === convTurnGeneration) {
+        clearAudio();
+        const wrap = assistantBubble.closest('.conv-bubble-wrap');
+        if (wrap) wrap.remove();
+        addErrorBubble(e.message);
+        addHistoryItem(turnN, Date.now() - t0, false);
+        if (micStatus) micStatus.textContent = 'Error — ready';
+      }
     } finally {
-      liveInterimText = '';
-      previewTranscribing = false;
-      isProcessing = false;
-      micBtn.classList.remove('processing');
-      micIcon.className = 'mdi mdi-microphone';
-      if (sendBtn) sendBtn.disabled = false;
-      if (textInput) { textInput.disabled = false; textInput.value = ''; }
-      if (audioQueuePlaying || audioQueue.length || convCurrentAudio) startInterruptMonitor();
+      if (turnGen === convTurnGeneration) {
+        convAbortCtrl = null;
+        liveInterimText = '';
+        previewTranscribing = false;
+        isProcessing = false;
+        micBtn.classList.remove('processing');
+        micIcon.className = 'mdi mdi-microphone';
+        setLiveMicUi();
+        if (sendBtn) sendBtn.disabled = false;
+        if (textInput) { textInput.disabled = false; textInput.value = ''; }
+        if (audioQueuePlaying || audioQueue.length || convCurrentAudio) startInterruptMonitor();
+        else if (liveMicEnabled) scheduleAutoMic();
+      }
     }
   }
 
@@ -866,8 +965,13 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
     form.append('system_prompt', systemPrompt?.value.trim() || 'You are a helpful voice assistant.');
     form.append('history', JSON.stringify(conversationHistory.slice(-20)));
 
+    const turnGen = ++convTurnGeneration;
+    if (convAbortCtrl) { try { convAbortCtrl.abort(); } catch(_){} }
+    convAbortCtrl = new AbortController();
+    if (liveMicEnabled || liveAgentOn()) startInterruptMonitor();
+
     try {
-      const resp = await fetch('/api/conversation/turn', { method: 'POST', body: form });
+      const resp = await fetch('/api/conversation/turn', { method: 'POST', body: form, signal: convAbortCtrl.signal });
       if (!resp.ok) { let _d=''; try { const _j=await resp.json(); _d=JSON.stringify(_j.detail||_j); } catch(_){} throw new Error('Server error ' + resp.status + (_d ? ': ' + _d : '')); }
       const reader = resp.body.getReader();
       const dec = new TextDecoder();
@@ -875,11 +979,13 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
 
       while (true) {
         const { done, value } = await reader.read();
+        if (turnGen !== convTurnGeneration) return;
         if (done) break;
         buf += dec.decode(value, { stream: true });
         const lines = buf.split('\n');
         buf = lines.pop();
         for (const line of lines) {
+          if (turnGen !== convTurnGeneration) return;
           if (!line.startsWith('data:')) continue;
           let evt;
           try { evt = JSON.parse(line.slice(5).trim()); } catch(_) { continue; }
@@ -918,32 +1024,60 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
         }
       }
     } catch(e) {
-      clearAudio();
-      const wrap = assistantBubble.closest('.conv-bubble-wrap');
-      if (wrap) wrap.remove();
-      addErrorBubble(e.message);
-      addHistoryItem(turnN, Date.now() - t0, false);
-      if (micStatus) micStatus.textContent = 'Error — ready';
+      if (e?.name !== 'AbortError' && turnGen === convTurnGeneration) {
+        clearAudio();
+        const wrap = assistantBubble.closest('.conv-bubble-wrap');
+        if (wrap) wrap.remove();
+        addErrorBubble(e.message);
+        addHistoryItem(turnN, Date.now() - t0, false);
+        if (micStatus) micStatus.textContent = 'Error — ready';
+      }
     } finally {
-      isProcessing = false;
-      micBtn.classList.remove('processing');
-      micIcon.className = 'mdi mdi-microphone';
-      if (sendBtn) sendBtn.disabled = false;
-      if (textInput) textInput.disabled = false;
-      if (audioQueuePlaying || audioQueue.length || convCurrentAudio) startInterruptMonitor();
+      if (turnGen === convTurnGeneration) {
+        convAbortCtrl = null;
+        isProcessing = false;
+        micBtn.classList.remove('processing');
+        micIcon.className = 'mdi mdi-microphone';
+        setLiveMicUi();
+        if (sendBtn) sendBtn.disabled = false;
+        if (textInput) textInput.disabled = false;
+        if (audioQueuePlaying || audioQueue.length || convCurrentAudio) startInterruptMonitor();
+        else if (liveMicEnabled) scheduleAutoMic();
+      }
     }
   }
 
+  async function enableLiveMic() {
+    liveMicEnabled = true;
+    autoMicGeneration++;
+    if (vadToggle) vadToggle.checked = true;
+    if (handsFreeToggle) handsFreeToggle.checked = true;
+    if (liveAgentToggle) liveAgentToggle.checked = true;
+    setLiveMicUi('Listening…');
+    if (isRecording()) return;
+    if (agentBusy()) await interruptAgentAndListen();
+    else await startRecording();
+  }
+
+  function disableLiveMic() {
+    liveMicEnabled = false;
+    if (handsFreeToggle) handsFreeToggle.checked = false;
+    autoMicGeneration++;
+    abortCurrentTurn();
+    clearAudio();
+    if (isRecording()) {
+      cancelRecordingOnStop = true;
+      stopRecording();
+    } else {
+      resetConversationInput('Mic off');
+    }
+    setLiveMicUi('Mic off');
+  }
+
   // ── Wire up events ───────────────────────────────────────────────────────
   micBtn.addEventListener('click', () => {
-    if (isProcessing) return;
-    autoMicGeneration++; // cancel any pending hands-free auto-restart
-    if (mediaRecorder && mediaRecorder.state === 'recording') {
-      stopRecording();
-    } else {
-      clearAudio(); // stop agent if still speaking
-      startRecording();
-    }
+    if (liveMicEnabled) disableLiveMic();
+    else enableLiveMic().catch(() => {});
   });
 
   // Text input — Enter key or Send button
@@ -965,11 +1099,28 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
   ttsFetchBtn?.addEventListener('click', fetchConvTtsVoices);
 
   // Re-populate TTS when backend changes
-  ttsBkSel?.addEventListener('change', () => { ttsVoiceSel.innerHTML = ''; });
+  ttsBkSel?.addEventListener('change', () => {
+    ttsVoiceSel.innerHTML = '';
+    if (window.VoicePicker) {
+      VoicePicker.upgrade('conv-tts-voice-select');
+      VoicePicker.populate('conv-tts-voice-select', []);
+    }
+  });
+  liveAgentToggle?.addEventListener('change', () => {
+    if (liveAgentToggle.checked) {
+      if (vadToggle) vadToggle.checked = true;
+      if (handsFreeToggle) handsFreeToggle.checked = true;
+      toast('Live agent mode on: faster silence cut-off and barge-in', 'success');
+    } else {
+      toast('Live agent mode off', 'info');
+    }
+  });
 
   // ── Init ─────────────────────────────────────────────────────────────────
   loadConvSttBackends();
   populateConvTtsBackends();
+  if (window.VoicePicker) VoicePicker.upgrade('conv-tts-voice-select');
+  setLiveMicUi();
 
   // Keep TTS backend select in sync after global backend refresh
   window._ttsRefreshHooks = window._ttsRefreshHooks || [];
diff --git a/static/js/engines.js b/static/js/engines.js
index ab1f611..8d6fffb 100644
--- a/static/js/engines.js
+++ b/static/js/engines.js
@@ -201,13 +201,23 @@ function saveCustomEngineCards(cards) {
 
 // Settings key per docker container name; role fallback for custom cards
 const DC_USE_MAP = {
-  'faster-qwen3-tts-voiceclone':  { settingKey: 'tts_url',        label: 'Use as TTS' },
-  'faster-qwen3-tts-voicedesign': { settingKey: 'tts_url',        label: 'Use as TTS' },
-  'faster-qwen3-tts-customvoice': { settingKey: 'tts_url',        label: 'Use as TTS' },
-  'faster-qwen3-tts-streaming':   { settingKey: 'tts_stream_url', label: 'Use as Streaming TTS' },
-  'parakeet-asr':                 { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
-  'magpie-tts':                   { settingKey: 'nvidia_tts_url', label: 'Use as TTS' },
-  'parakeet-rnnt-nim':            { settingKey: 'nvidia_asr_url', label: 'Use as STT' },
+  'faster-qwen3-tts-voiceclone':  { settingKey: 'tts_url',          label: 'Use as TTS' },
+  'faster-qwen3-tts-voicedesign': { settingKey: 'voice_design_url', label: 'Use as Voice Design' },
+  'faster-qwen3-tts-customvoice': { settingKey: 'customvoice_url',  label: 'Use as CustomVoice' },
+  'faster-qwen3-tts-streaming':   { settingKey: 'tts_stream_url',   label: 'Use as Streaming TTS' },
+  'faster-whisper':               { settingKey: 'faster_whisper_url', label: 'Use as STT' },
+  'fish-speech-api':              { settingKey: 'fishspeech_url',   label: 'Use as Fish-Speech' },
+  'nemotron-asr-streaming':       { settingKey: 'faster_whisper_url', label: 'Use as STT' },
+  'parakeet-asr':                 { settingKey: 'nvidia_asr_url',   label: 'Use as STT' },
+  'parakeet-stt-multi-9011':      { settingKey: 'nvidia_asr_url',   label: 'Use as STT' },
+  'magpie-tts':                   { settingKey: 'nvidia_tts_url',   label: 'Use as TTS' },
+  'nvidia-magpie-tts':            { settingKey: 'nvidia_tts_url',   label: 'Use as TTS' },
+  'nvidia-parakeet-asr':          { settingKey: 'nvidia_router_url', label: 'Use as STT Router' },
+  'parakeet-rnnt-nim':            { settingKey: 'nvidia_asr_url',   label: 'Use as STT' },
+  'nvidia-parakeet-rnnt-nim':     { settingKey: 'nvidia_asr_url',   label: 'Use as STT' },
+  'vibevoice-msft-tts':           { settingKey: 'vibevoice_url',    label: 'Use as VibeVoice' },
+  'xtts-dgx-spark':               { settingKey: 'xtts_url',         label: 'Use as XTTS' },
+  'whisperx-blackwell':           { settingKey: 'faster_whisper_url', label: 'Use as STT' },
 };
 const DC_ROLE_USE = {
   tts: { settingKey: 'tts_url',              label: 'Use as TTS' },
@@ -330,6 +340,17 @@ function renderLocalContainers(containers) {
     'parakeet-asr':                 '🦜',
     'magpie-tts':                   '🐦',
     'parakeet-rnnt-nim':            '🦜',
+    'faster-whisper':                '',
+    'fish-speech-api':               '',
+    'nvidia-magpie-tts':             '',
+    'nvidia-parakeet-asr':           '',
+    'nvidia-parakeet-rnnt-nim':      '',
+    'vibevoice-msft-tts':            '',
+    'xtts-dgx-spark':                '',
+    'litellm':                       '',
+    'llama.cpp':                     '',
+    'ollama':                        '',
+    'llama-swap':                    '',
   };
   const _t = (v) => ``;
   const _l = (v) => ``;
@@ -343,6 +364,14 @@ function renderLocalContainers(containers) {
     'magpie-tts':                   [[_t(),'~0.05× GPU'],[_l(),'0.3–0.8 s'],[_q(),'High'],    [_m(),'4–6 GB VRAM']],
     'parakeet-asr':                 [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-TDT'],[_m(),'2 GB VRAM']],
     'parakeet-rnnt-nim':            [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-1B'], [_m(),'2 GB VRAM']],
+    'faster-whisper':                [[_t(),'~70× RT GPU'],[_l(),'0.5–2 s'],[_q(),'Whisper large-v3'],[_m(),'~1.5 GB VRAM']],
+    'fish-speech-api':               [[_t(),'~0.4× GPU'],[_l(),'1–4 s'],[_q(),'Tone + clone'],[_m(),'~20 GB VRAM']],
+    'vibevoice-msft-tts':            [[_t(),'fast'],[_l(),'0.2–1 s'],[_q(),'TTS'],[_m(),'~2 GB']],
+    'xtts-dgx-spark':                [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'XTTS v2'],[_m(),'~3 GB']],
+    'litellm':                       [[_t(),'proxy'],[_l(),'route'],[_q(),'OpenAI API'],[_m(),'~600 MB']],
+    'llama.cpp':                     [[_t(),'local'],[_l(),'model dependent'],[_q(),'OpenAI API'],[_m(),'model dependent']],
+    'ollama':                        [[_t(),'local'],[_l(),'model dependent'],[_q(),'OpenAI API'],[_m(),'model dependent']],
+    'llama-swap':                    [[_t(),'swap'],[_l(),'warm after load'],[_q(),'OpenAI API'],[_m(),'model dependent']],
   };
   const roleIcon = { tts: '', stt: '', 'stt+tts': '', llm: '' };
 
diff --git a/static/js/rehearser.js b/static/js/rehearser.js
index c0805be..e76e81f 100644
--- a/static/js/rehearser.js
+++ b/static/js/rehearser.js
@@ -81,6 +81,7 @@ const rehState = {
   practiceEnd:   null,
   bulkMode:   false,        // bulk-edit (line selection) mode on/off
   bulkSel:    new Set(),    // indices of currently selected lines
+  bulkAnchor: null,         // last clicked index for Shift+click range selection
   showHidden: false,        // reveal hidden lines (so they can be restored)
   recStream: null, recAudioCtx: null, recAnalyser: null,
   recSourceNode: null, recGainNode: null, recDestStream: null,
@@ -669,6 +670,13 @@ function getVoiceData(voiceId) {
   return (window._voices || []).find(v => v.id === voiceId) || null;
 }
 
+function _rehVoiceVisibleId(voiceId, include = '') {
+  if (!voiceId) return false;
+  if (include && voiceId === include) return true;
+  const v = getVoiceData(voiceId);
+  return !v || v.enabled !== false;
+}
+
 function voiceAvatarHtml(voiceId, color, size = 32) {
   const v = getVoiceData(voiceId);
   const s = size + 'px';
@@ -1045,6 +1053,7 @@ function _rehRenderLocalResults(card, sp, q) {
   const cur = rehState.cast[sp]?.voice;
   const ql = (q || '').trim().toLowerCase();
   const matches = rehState.voices.filter(v => {
+    if (!_rehVoiceVisibleId(v, cur)) return false;
     if (v === cur) return false;
     if (!ql) return true;
     const vd = getVoiceData(v);
@@ -1193,6 +1202,13 @@ function _castApplyToLines(sp, fn) {
   rehState.lines.forEach((l, i) => { if (l.speaker === sp && l.type === 'dialog') fn(l, i); });
 }
 
+function _safeDomId(value) {
+  let out = '';
+  const s = String(value || 'voice');
+  for (let i = 0; i < s.length; i++) out += s.charCodeAt(i).toString(36) + '-';
+  return out || 'voice';
+}
+
 function renderCastList() {
   const list = $('reh-cast-list'); if (!list) return;
   // Make sure the voice library is loaded so every picker (incl. the narrator) has
@@ -1219,7 +1235,8 @@ function renderCastList() {
     const cardCls = 'reh-cast-card' + (isNarr ? ' reh-cast-narrator' : '') + (c.ignored ? ' reh-cast-ignored' : '') + (c.hidden ? ' reh-cast-hidden-c' : '');
     const langSel = REH_CAST_LANGS.map(l => `${l}`).join('');
     const genSel  = REH_CAST_GENDERS.map(([v,t]) => ``).join('');
-    const voiceSel = `
         
         ${_rehAllVoiceIds(c.voice).map(v=>``).join('')}
       `;
@@ -1279,6 +1296,14 @@ function renderCastList() {
   const card = el => el.closest('.reh-cast-card');
   const spOf = el => card(el).dataset.speaker;
 
+  if (window.VoicePicker) {
+    list.querySelectorAll('.reh-voice-sel[id]').forEach(sel => {
+      const cur = sel.value;
+      VoicePicker.upgrade(sel.id);
+      if (cur) VoicePicker.setValue(sel.id, cur);
+    });
+  }
+
   list.querySelectorAll('.reh-me-check').forEach(cb => cb.addEventListener('change', function () {
     const sp = spOf(this);
     rehState.cast[sp].voice = this.checked ? 'me' : (card(this).querySelector('.reh-voice-sel')?.value || '');
@@ -1528,8 +1553,8 @@ async function refreshRehBackends() {
 // All voice IDs offered in the cast/narrator pickers — backend voices PLUS everything
 // in the library, so any saved/cloned/imported voice (incl. for the narrator) is pickable.
 function _rehAllVoiceIds(include) {
-  const ids = new Set(rehState.voices || []);
-  (window._voices || []).forEach(v => { if (v && v.id) ids.add(v.id); });
+  const ids = new Set((rehState.voices || []).filter(id => _rehVoiceVisibleId(id, include)));
+  (window._voices || []).forEach(v => { if (v && v.id && (v.enabled !== false || v.id === include)) ids.add(v.id); });
   if (include) ids.add(include);
   return [...ids].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
 }
@@ -1546,8 +1571,12 @@ $('reh-fetch-voices-btn')?.addEventListener('click', async () => {
   if (!backend) { toast('Select a backend first', 'error'); return; }
   $('reh-fetch-voices-btn').disabled = true;
   try {
+    if ((!window._voices || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
+      await loadVoiceLibrary().catch(() => {});
+    }
     const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
-    rehState.voices = Array.isArray(raw) ? raw.map(v => typeof v === 'string' ? v : (v.id || String(v))) : [];
+    rehState.voices = (Array.isArray(raw) ? raw.map(v => typeof v === 'string' ? v : (v.id || String(v))) : [])
+      .filter(id => _rehVoiceVisibleId(id));
     renderCastList();
     populateNarratorSelect();
     toast('Fetched ' + rehState.voices.length + ' voices', 'success');
@@ -2118,7 +2147,7 @@ function buildScriptPage() {
     const bulkCheck = rehState.bulkMode
       ? ``
       : '';
-    const lineFlags = (line.ignored ? ' reh-line-ignored' : '') + (line.hidden ? ' reh-line-hidden' : '');
+    const lineFlags = (line.ignored ? ' reh-line-ignored' : '') + (line.hidden ? ' reh-line-hidden' : '') + (_bSel ? ' reh-selected' : '');
 
     switch (line.type) {
       case 'act':
@@ -2156,7 +2185,7 @@ function buildScriptPage() {
           ${bulkCheck}
           
- ${voiceAvatarHtml(isMe?'':c.voice, c.color, 32)} + ${escHtml(line.speaker)} ${isMe @@ -2201,6 +2230,20 @@ function buildScriptPage() { el.addEventListener('dblclick', e => { e.stopPropagation(); startInlineEdit(parseInt(el.closest('[data-index]').dataset.index)); }); }); + // Avatar button: play/pause a single character line + linesEl.querySelectorAll('.reh-line-play-avatar').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.index); + if (rehState.playing && rehState.lineIndex === idx) { pausePlay(); return; } + stopPlay(); + hideRecOverlay(); + rehState.lineIndex = idx; + highlightCurrentLine(); + startPlay(); + }); + }); + // Gutter button: play from here / set end linesEl.querySelectorAll('.reh-gutter-btn').forEach(btn => { btn.addEventListener('click', e => { @@ -2221,13 +2264,18 @@ function buildScriptPage() { }); }); - // Click block to jump + // Click block to jump; in Select mode, click toggles selection and Shift+click selects a range. linesEl.querySelectorAll('[data-index]').forEach(el => { el.addEventListener('click', e => { - if (e.target.closest('.reh-emo-btn, .reh-edit-btn, .reh-gutter-btn, .reh-note-btn, .reh-resynth-btn, textarea, select')) return; + if (e.target.closest('.reh-emo-btn, .reh-edit-btn, .reh-gutter-btn, .reh-note-btn, .reh-resynth-btn, .reh-line-play-avatar, textarea, select, .vp-root')) return; + const idx = parseInt(el.dataset.index); + if (rehState.bulkMode) { + _toggleBulkSel(idx, e.shiftKey); + return; + } stopPlay(); hideRecOverlay(); - rehState.lineIndex = parseInt(el.dataset.index); + rehState.lineIndex = idx; highlightCurrentLine(); }); }); @@ -2260,13 +2308,15 @@ function buildScriptPage() { ta.addEventListener('click', e => e.stopPropagation()); }); - // Bulk-edit: leading checkboxes toggle line selection + // Bulk-edit: leading checkboxes toggle line selection. The page wrapper also + // gets the mode class because A4/PDF modes move blocks out of #reh-script-lines. linesEl.classList.toggle('reh-bulk-mode', rehState.bulkMode); + document.querySelector('.reh-page-wrap')?.classList.toggle('reh-bulk-mode', rehState.bulkMode); if (rehState.bulkMode) { linesEl.querySelectorAll('.reh-bulk-check').forEach(cb => { cb.addEventListener('click', e => { e.stopPropagation(); - _toggleBulkSel(parseInt(cb.dataset.bulk)); + _toggleBulkSel(parseInt(cb.dataset.bulk), e.shiftKey); }); }); } @@ -2280,10 +2330,26 @@ function buildScriptPage() { // ── Bulk-edit (line selection: ignore / hide / delete) ────────────────────── -function _toggleBulkSel(i) { - if (rehState.bulkSel.has(i)) rehState.bulkSel.delete(i); - else rehState.bulkSel.add(i); - _refreshBulkLine(i); +function _toggleBulkSel(i, range = false) { + if (!Number.isFinite(i)) return; + if (range && rehState.bulkAnchor !== null && Number.isFinite(rehState.bulkAnchor)) { + const a = Math.min(rehState.bulkAnchor, i); + const b = Math.max(rehState.bulkAnchor, i); + const indices = []; + for (let n = a; n <= b; n++) if (rehState.lines[n] && !rehState.lines[n].hidden) indices.push(n); + const deselect = indices.length && indices.every(n => rehState.bulkSel.has(n)); + indices.forEach(n => { + if (deselect) rehState.bulkSel.delete(n); + else rehState.bulkSel.add(n); + _refreshBulkLine(n); + }); + rehState.bulkAnchor = i; + } else { + if (rehState.bulkSel.has(i)) rehState.bulkSel.delete(i); + else rehState.bulkSel.add(i); + rehState.bulkAnchor = i; + _refreshBulkLine(i); + } _updateBulkCount(); } @@ -2294,6 +2360,7 @@ function _refreshBulkLine(i) { const icon = cb.querySelector('.mdi'); if (icon) icon.className = 'mdi ' + (sel ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'); }); + document.querySelectorAll(`[data-index="${i}"]`).forEach(el => el.classList.toggle('reh-selected', sel)); } function _updateBulkCount() { @@ -2303,7 +2370,7 @@ function _updateBulkCount() { function setBulkMode(on) { rehState.bulkMode = on; - if (!on) rehState.bulkSel.clear(); + if (!on) { rehState.bulkSel.clear(); rehState.bulkAnchor = null; } const bar = $('reh-bulk-bar'); if (bar) bar.hidden = !on; const btn = $('reh-bulk-toggle'); if (btn) btn.classList.toggle('active', on); _updateBulkCount(); @@ -2622,6 +2689,7 @@ function updatePracticeRange() { if (info) { const show = end !== null; info.classList.toggle('visible', show); + info.hidden = !show; if (show) { const f = $('reh-practice-from'), t = $('reh-practice-to'); if (f) f.textContent = (start !== null ? start : 0) + 1; @@ -2636,7 +2704,20 @@ function clearPracticeRange() { updatePracticeRange(); } +function setPracticeRangeFromSelection() { + const picked = [...rehState.bulkSel].sort((a, b) => a - b); + if (!picked.length) { toast('Select the lines you want to rehearse first', 'error'); return; } + rehState.practiceStart = picked[0]; + rehState.practiceEnd = picked[picked.length - 1]; + rehState.lineIndex = rehState.practiceStart; + setBulkMode(false); + updatePracticeRange(); + highlightCurrentLine(); + toast(`Practice range set: lines ${rehState.practiceStart + 1}–${rehState.practiceEnd + 1}`, 'success'); +} + $('reh-practice-clear')?.addEventListener('click', clearPracticeRange); +$('reh-bulk-range')?.addEventListener('click', setPracticeRangeFromSelection); // ── Emotion picker popover ────────────────────────────────────────────────── @@ -2809,7 +2890,8 @@ function _checkToneStyleSupport() { } else if (b.style_aware && !b.uses_wav) { const wavBackend = all.find(x => x.uses_wav); const suggest = wavBackend ? ` Switch to ${escHtml(wavBackend.label)} to keep each character’s voice identical throughout.` : ''; - if (txtEl) txtEl.innerHTML = `${escHtml(b.label)} gives strong tone but re-generates a fresh voice each line, so a character won’t sound the same throughout.${suggest}`; + const qwenHint = /qwen|voice design|custom/i.test((b.id || '') + ' ' + (b.label || '')) ? ' Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.' : ''; + if (txtEl) txtEl.innerHTML = `${escHtml(b.label)} gives strong tone but re-generates a fresh voice each line, so a character won’t sound the same throughout.${qwenHint}${suggest}`; warn.hidden = false; } else { warn.hidden = true; @@ -3589,6 +3671,8 @@ $('reh-impex-md-btn')?.addEventListener('click', exportAsMd); const closeBtn = $('reh-imsdb-close'); const coverBtn = $('reh-imsdb-cover-btn'); const listBtn = $('reh-imsdb-list-btn'); + const urlInp = $('reh-imsdb-url'); + const urlBtn = $('reh-imsdb-url-fetch'); if (!modal || !grid) return; const CACHE_KEY = 'reh-imsdb-cat-v1'; @@ -3596,7 +3680,7 @@ $('reh-impex-md-btn')?.addEventListener('click', exportAsMd); let _catalogue = null; let _posterObserver = null; - let _view = localStorage.getItem('reh-imsdb-view') || 'cover'; // 'cover' | 'list' + let _view = localStorage.getItem('reh-imsdb-view') || 'list'; // 'cover' | 'list' function _applyView() { grid.classList.toggle('list-view', _view === 'list'); @@ -3672,29 +3756,38 @@ $('reh-impex-md-btn')?.addEventListener('click', exportAsMd); _applyView(); } - async function importImsdb(it) { - // In-modal loading indicator — IMSDb resolution can take a couple seconds - // (detail page → real "Read Script" link → script page). - grid.innerHTML = `
Fetching “${escHtml(it.title)}” from IMSDb…
`; + async function importAnyWebUrl(url, label = 'script') { + url = String(url || '').trim(); + if (!url) { toast('Paste a script URL first', 'error'); return; } + if (!/^https?:\/\//i.test(url)) { toast('URL must start with http:// or https://', 'error'); return; } + if (urlBtn) urlBtn.disabled = true; + grid.innerHTML = `
Fetching ${escHtml(label)}…
`; try { const r = await fetch('/api/fetch-web-script', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: it.fetch_url }), + body: JSON.stringify({ url }), }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); const ta = $('reh-script-text'); if (ta) ta.value = d.text; - const ti = $('reh-script-title'); if (ti) ti.value = it.title; + const ti = $('reh-script-title'); if (ti) ti.value = d.title || label; closeModal(); navRehearserPhase(1); setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'center' }), 250); - toast(`Loaded "${it.title}" (${(d.chars/1000).toFixed(0)} K) — click "Parse & cast"`, 'success'); + toast(`Loaded "${d.title || label}" (${(d.chars/1000).toFixed(0)} K) — click "Parse & cast"`, 'success'); } catch(e) { - renderGrid(_searchFilter(_catalogue)); // restore the grid so the user can retry - toast('IMSDb fetch failed: ' + e.message, 'error'); + grid.innerHTML = `
Fetch failed: ${escHtml(e.message)}
`; + if (_catalogue) renderGrid(_searchFilter(_catalogue)); + toast('Script fetch failed: ' + e.message, 'error'); + } finally { + if (urlBtn) urlBtn.disabled = false; } } + async function importImsdb(it) { + await importAnyWebUrl(it.fetch_url, it.title || 'IMSDb script'); + } + async function _loadCatalogue() { // Try localStorage cache first try { @@ -3717,6 +3810,7 @@ $('reh-impex-md-btn')?.addEventListener('click', exportAsMd); async function openModal() { modal.hidden = false; + if (urlInp) urlInp.value = ''; _applyView(); if (!_catalogue) { grid.innerHTML = '
Loading catalogue…
'; @@ -3746,6 +3840,14 @@ $('reh-impex-md-btn')?.addEventListener('click', exportAsMd); renderGrid(_searchFilter(_catalogue)); }, 150); }); + search?.addEventListener('keydown', e => { + if (e.key === 'Enter' && /^https?:\/\//i.test(search.value.trim())) { + e.preventDefault(); + importAnyWebUrl(search.value.trim(), 'pasted URL'); + } + }); + urlBtn?.addEventListener('click', () => importAnyWebUrl(urlInp?.value || '', 'pasted URL')); + urlInp?.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); importAnyWebUrl(urlInp.value, 'pasted URL'); } }); coverBtn?.addEventListener('click', () => { _view = 'cover'; localStorage.setItem('reh-imsdb-view', _view); _applyView(); @@ -4132,12 +4234,14 @@ $('reh-bulk-done')?.addEventListener('click', () => setBulkMode(false)); $('reh-bulk-all')?.addEventListener('click', () => { // Select every line currently visible in the stage document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => rehState.bulkSel.add(parseInt(cb.dataset.bulk))); + rehState.bulkAnchor = rehState.bulkSel.size ? Math.min(...rehState.bulkSel) : null; document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => _refreshBulkLine(parseInt(cb.dataset.bulk))); _updateBulkCount(); }); $('reh-bulk-none')?.addEventListener('click', () => { const had = [...rehState.bulkSel]; rehState.bulkSel.clear(); + rehState.bulkAnchor = null; had.forEach(_refreshBulkLine); _updateBulkCount(); }); diff --git a/static/js/utils.js b/static/js/utils.js index e411916..431fd7a 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -12,9 +12,15 @@ function avatarColor(id) { } let _toastTimer; -function toast(msg, type = '', ms = 3500) { - const el = $('toast'); el.textContent = msg; el.className = 'show ' + type; - clearTimeout(_toastTimer); _toastTimer = setTimeout(() => el.className = '', ms); +function toast(msg, type = '', ms = 6500) { + const el = $('toast'); + if (!el) return; + el.textContent = msg; + el.setAttribute('role', type === 'error' ? 'alert' : 'status'); + el.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite'); + el.className = 'show ' + type; + clearTimeout(_toastTimer); + _toastTimer = setTimeout(() => el.className = '', ms); } function status(msg) { $('status-bar').textContent = msg; } function escHtml(s) { diff --git a/static/js/voice-picker.js b/static/js/voice-picker.js index 2733868..ab741f9 100644 --- a/static/js/voice-picker.js +++ b/static/js/voice-picker.js @@ -125,6 +125,7 @@ function _open() { drop.hidden = false; + drop.style.display = ''; trigger.classList.add('open'); searchInp.value = ''; _renderList(''); @@ -138,7 +139,9 @@ function _close() { drop.hidden = true; + drop.style.display = 'none'; trigger.classList.remove('open'); + searchInp.blur(); } function _select(value) { @@ -146,6 +149,7 @@ sel.dispatchEvent(new Event('change', { bubbles: true })); _syncTrigger(); _close(); + trigger.focus(); } function _syncTrigger() { @@ -168,6 +172,12 @@ if (e.key === 'ArrowDown') { e.preventDefault(); list.querySelector('.vp-item')?.focus(); } }); + list.addEventListener('pointerdown', e => { + const item = e.target.closest('.vp-item'); + if (!item) return; + e.preventDefault(); + _select(item.dataset.value); + }); list.addEventListener('click', e => { const item = e.target.closest('.vp-item'); if (item) _select(item.dataset.value); @@ -186,6 +196,12 @@ if (!root.contains(e.target)) _close(); }, true); + // Any real select change means the user picked something; close the popup. + sel.addEventListener('change', () => { + _syncTrigger(); + _close(); + }); + // Watch for programmatic changes to the underlying select const mo = new MutationObserver(() => { _syncTrigger(); }); mo.observe(sel, { childList: true, attributes: true, subtree: true }); diff --git a/static/sections/s-conversation.html b/static/sections/s-conversation.html index 98379b5..4393f31 100644 --- a/static/sections/s-conversation.html +++ b/static/sections/s-conversation.html @@ -75,6 +75,10 @@ Hands-free +
diff --git a/static/sections/s-llms.html b/static/sections/s-llms.html index c076c98..f67370e 100644 --- a/static/sections/s-llms.html +++ b/static/sections/s-llms.html @@ -573,7 +573,7 @@ cd whisper.cpp && cmake -B build && cmake --build build -j

Text to Speech

-

Your Docker stack containers appear at the top. Click Use as TTS to apply a URL to this app’s backend settings.

+

Docker-detected engines and local presets are listed together here. Click Use as TTS to apply a URL to this app’s backend settings.

@@ -582,10 +582,9 @@ cd whisper.cpp && cmake -B build && cmake --build build -j
-
+
-

Other local TTS

-
+
diff --git a/static/sections/s-rehearser.html b/static/sections/s-rehearser.html index 6f4b032..ecb5f56 100644 --- a/static/sections/s-rehearser.html +++ b/static/sections/s-rehearser.html @@ -24,12 +24,12 @@

Import from URL

- +
+