Improve voice workflows and add user manual

This commit is contained in:
mARTin-B78 2026-06-04 08:23:18 +02:00
parent 40e42590cc
commit 9c2e507ccf
16 changed files with 1319 additions and 132 deletions

1
.gitignore vendored
View File

@ -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

View File

@ -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
```

View File

@ -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"}

View File

@ -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

714
docs/USER_MANUAL.md Normal file
View File

@ -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://<server-ip>: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 |

View File

@ -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."},
]

View File

@ -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'<hr[^>]*>\s*(?:<[^>]+>\s*)*(?:<a[^>]+>[^<]*Prev[^<]*</a>|&laquo;|«|&lsaquo;)', 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'<br\s*/?>', '\n', body, flags=re.I)
body = re.sub(r'</p\s*>', '\n', body, flags=re.I)
body = re.sub(r'</div\s*>', '\n', body, flags=re.I)
body = re.sub(r'<a[^>]+href="https?://www\.(?:definitions|abbreviations)\.com/[^>]+>(.*?)</a>', 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 <pre> block."""
m = re.search(r"<pre[^>]*>([\s\S]+?)</pre>", 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 <pre> 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"<h1[^>]*>([\s\S]+?)</h1>|<title[^>]*>([\s\S]+?)</title>", 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()

View File

@ -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 `<option value="${escHtml(id)}">${escHtml(id)}</option>`; }).join('')
: '<option value="">No voices</option>';
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 => `<option value="${escHtml(id)}">${escHtml(id)}</option>`).join('')
: '<option value="">No voices</option>';
if (prev && ids.includes(prev)) ttsVoiceSel.value = prev;
}
} catch(e) {
ttsVoiceSel.innerHTML = '<option value="">Fetch failed</option>';
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 = '<option value="">— fetch voices —</option>'; });
ttsBkSel?.addEventListener('change', () => {
ttsVoiceSel.innerHTML = '<option value="">— fetch voices —</option>';
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 || [];

View File

@ -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': '<span class="mdi mdi-waveform"></span>',
'fish-speech-api': '<span class="mdi mdi-emoticon-outline"></span>',
'nvidia-magpie-tts': '<span class="mdi mdi-speaker-wireless"></span>',
'nvidia-parakeet-asr': '<span class="mdi mdi-microphone-variant"></span>',
'nvidia-parakeet-rnnt-nim': '<span class="mdi mdi-microphone-variant"></span>',
'vibevoice-msft-tts': '<span class="mdi mdi-account-voice"></span>',
'xtts-dgx-spark': '<span class="mdi mdi-account-music-outline"></span>',
'litellm': '<span class="mdi mdi-router-network"></span>',
'llama.cpp': '<span class="mdi mdi-chip"></span>',
'ollama': '<span class="mdi mdi-circle-outline"></span>',
'llama-swap': '<span class="mdi mdi-swap-horizontal"></span>',
};
const _t = (v) => `<span class="mdi mdi-lightning-bolt"></span>`;
const _l = (v) => `<span class="mdi mdi-clock-outline"></span>`;
@ -343,6 +364,14 @@ function renderLocalContainers(containers) {
'magpie-tts': [[_t(),'~0.05× GPU'],[_l(),'0.30.8 s'],[_q(),'High'], [_m(),'46 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.52 s'],[_q(),'Whisper large-v3'],[_m(),'~1.5 GB VRAM']],
'fish-speech-api': [[_t(),'~0.4× GPU'],[_l(),'14 s'],[_q(),'Tone + clone'],[_m(),'~20 GB VRAM']],
'vibevoice-msft-tts': [[_t(),'fast'],[_l(),'0.21 s'],[_q(),'TTS'],[_m(),'~2 GB']],
'xtts-dgx-spark': [[_t(),'~0.3× GPU'],[_l(),'13 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: '<span class="mdi mdi-speaker-outline"></span>', stt: '<span class="mdi mdi-microphone-variant"></span>', 'stt+tts': '<span class="mdi mdi-swap-horizontal"></span>', llm: '<span class="mdi mdi-robot-outline"></span>' };

View File

@ -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 => `<option${(c.lang||'')===l?' selected':''}>${l}</option>`).join('');
const genSel = REH_CAST_GENDERS.map(([v,t]) => `<option value="${v}"${(c.gender||'')===v?' selected':''}>${t}</option>`).join('');
const voiceSel = `<select class="reh-voice-sel" data-speaker="${escHtml(sp)}"${isMe?' style="display:none"':''}>
const pickerId = 'reh-voice-sel-' + _safeDomId(sp);
const voiceSel = `<select id="${pickerId}" class="reh-voice-sel" data-speaker="${escHtml(sp)}"${isMe?' style="display:none"':''}>
<option value=""> ${isNarr ? 'none (skip descriptions)' : 'assign a voice'} </option>
${_rehAllVoiceIds(c.voice).map(v=>`<option value="${escHtml(v)}"${v===c.voice?' selected':''}>${escHtml(v)}</option>`).join('')}
</select>`;
@ -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
? `<span class="reh-bulk-check${_bSel ? ' checked' : ''}" data-bulk="${i}" title="Select line"><span class="mdi ${_bSel ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'}"></span></span>`
: '';
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}<button class="reh-gutter-btn" data-index="${i}" title="Click: play from here · Shift+click: set end marker">${rehState.practiceStart===i?'▶':rehState.practiceEnd===i?'■':''}</button>
<div class="reh-block-inner">
<div class="reh-block-head">
${voiceAvatarHtml(isMe?'':c.voice, c.color, 32)}
<button type="button" class="reh-line-play-avatar" data-index="${i}" title="Play or pause this line" aria-label="Play or pause this line">${voiceAvatarHtml(isMe?'':c.voice, c.color, 32)}</button>
<span class="reh-block-name" style="color:${c.color}">${escHtml(line.speaker)}</span>
<span class="reh-block-head-spacer"></span>
${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 <strong>${escHtml(wavBackend.label)}</strong> to keep each characters voice identical throughout.` : '';
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> gives strong tone but re-generates a fresh voice each line, so a character wont 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 = `<strong>${escHtml(b.label)}</strong> gives strong tone but re-generates a fresh voice each line, so a character wont 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 = `<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Fetching “${escHtml(it.title)}” from IMSDb…</div>`;
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 = `<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Fetching ${escHtml(label)}…</div>`;
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 = `<div class="reh-imsdb-load-error">Fetch failed: ${escHtml(e.message)}</div>`;
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 = '<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Loading catalogue…</div>';
@ -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();
});

View File

@ -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) {

View File

@ -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 });

View File

@ -75,6 +75,10 @@
<input type="checkbox" id="conv-handsfree-toggle" checked>
<span>Hands-free</span>
</label>
<label class="conv-vad-label" title="Lower silence and interruption delays for live voice-agent turn-taking">
<input type="checkbox" id="conv-live-agent-toggle" checked>
<span>Live agent</span>
</label>
</div>
</div>
</div>

View File

@ -573,7 +573,7 @@ cd whisper.cpp && cmake -B build && cmake --build build -j
<div class="llm-sec-header">
<div>
<h3 class="llm-sec-title">Text to Speech</h3>
<p class="llm-sec-note">Your Docker stack containers appear at the top. Click <strong>Use as TTS</strong> to apply a URL to this app&rsquo;s backend settings.</p>
<p class="llm-sec-note">Docker-detected engines and local presets are listed together here. Click <strong>Use as TTS</strong> to apply a URL to this app&rsquo;s backend settings.</p>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn-secondary" type="button" onclick="openAddEngineDialog('tts')"><span class="mdi mdi-plus"></span> Add Custom</button>
@ -582,10 +582,9 @@ cd whisper.cpp && cmake -B build && cmake --build build -j
</div>
</div>
<div id="dc-grid-tts" class="dc-grid" style="margin-bottom:14px"></div>
<div id="dc-grid-tts" class="dc-grid tts-local-engines-grid"></div>
<h4 class="llm-local-cat">Other local TTS</h4>
<div class="llm-local-grid">
<div class="llm-local-grid tts-local-engines-grid">
<div class="llm-local-card">
<div class="llm-local-head">

View File

@ -24,12 +24,12 @@
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
<h4 class="reh-impex-cat" style="margin:0"><span class="mdi mdi-web"></span> Import from URL</h4>
<button class="btn-secondary btn-sm" id="reh-browse-imsdb-btn-impex" title="Browse 1300+ movie scripts from IMSDb"><span class="mdi mdi-movie-open-outline"></span> Browse IMSDb</button>
<button class="btn-secondary btn-sm" id="reh-browse-imsdb-btn-impex" title="Browse 1300+ movie scripts from IMSDb"><span class="mdi mdi-movie-open-outline"></span> Browse scripts</button>
</div>
<div class="reh-impex-url-row">
<span class="mdi mdi-web reh-impex-url-icon"></span>
<input id="reh-impex-url" type="text" class="reh-impex-url-inp"
placeholder="https://www.imsdb.com/scripts/… or any plain-text screenplay URL"
placeholder="https://www.imsdb.com/scripts/… or https://www.scripts.com/script/the_pianist_72/2"
spellcheck="false" autocomplete="off">
<button class="btn-primary" id="reh-impex-url-btn" type="button">
<span class="mdi mdi-download"></span> Fetch
@ -106,7 +106,7 @@
<div class="reh-lib-header">
<h2 style="margin:0"><span class="mdi mdi-bookshelf"></span> Rehearsal library</h2>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn-secondary btn-sm" id="reh-browse-imsdb-btn" title="Browse 1300+ movie scripts from IMSDb"><span class="mdi mdi-movie-open-outline"></span> Browse IMSDb</button>
<button class="btn-secondary btn-sm" id="reh-browse-imsdb-btn" title="Browse 1300+ movie scripts from IMSDb"><span class="mdi mdi-movie-open-outline"></span> Browse scripts</button>
<button class="btn-secondary btn-sm" id="reh-lib-view-toggle" title="Toggle shelf / list view"><span class="mdi mdi-view-list"></span></button>
<label class="btn-secondary" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
<span class="mdi mdi-import"></span> Import .reh
@ -332,8 +332,10 @@
<!-- Bulk-edit action bar (shown when "Select" mode is on) -->
<div class="reh-bulk-bar" id="reh-bulk-bar" hidden>
<span class="reh-bulk-count" id="reh-bulk-count">0 selected</span>
<span class="reh-bulk-hint">Click lines to select · Shift-click range</span>
<button class="btn-secondary btn-sm" id="reh-bulk-all" title="Select all visible lines"><span class="mdi mdi-select-all"></span> All</button>
<button class="btn-secondary btn-sm" id="reh-bulk-none" title="Clear selection">Clear</button>
<button class="btn-secondary btn-sm" id="reh-bulk-range" title="Set playback range to the selected lines"><span class="mdi mdi-playlist-play"></span> Set range</button>
<span class="reh-bulk-sep"></span>
<button class="btn-secondary btn-sm" id="reh-bulk-ignore" title="Grey out &amp; skip these lines during playback/synth"><span class="mdi mdi-eye-off-outline"></span> Ignore</button>
<button class="btn-secondary btn-sm" id="reh-bulk-unignore" title="Restore ignored lines"><span class="mdi mdi-eye-outline"></span> Un-ignore</button>
@ -507,15 +509,20 @@
<div class="reh-imsdb-modal" id="reh-imsdb-modal" hidden>
<div class="reh-imsdb-box">
<div class="reh-imsdb-head">
<h3><span class="mdi mdi-movie-open-outline"></span> IMSDb Script Library</h3>
<input type="text" id="reh-imsdb-search" class="reh-imsdb-search" placeholder="Search 1300+ movie scripts…" spellcheck="false" autocomplete="off">
<h3><span class="mdi mdi-movie-open-outline"></span> Script Library</h3>
<input type="text" id="reh-imsdb-search" class="reh-imsdb-search" placeholder="Search IMSDb titles…" spellcheck="false" autocomplete="off">
<span id="reh-imsdb-count" class="reh-imsdb-count"></span>
<a class="btn-secondary btn-sm reh-scripts-link" href="https://www.scripts.com" target="_blank" rel="noopener" title="Open Scripts.com"><span class="mdi mdi-open-in-new"></span> Scripts.com</a>
<div class="reh-imsdb-view-btns">
<button class="reh-imsdb-view-btn active" id="reh-imsdb-cover-btn" title="Cover view"><span class="mdi mdi-view-grid-outline"></span></button>
<button class="reh-imsdb-view-btn" id="reh-imsdb-list-btn" title="List view"><span class="mdi mdi-view-list-outline"></span></button>
</div>
<button class="btn-secondary btn-sm" id="reh-imsdb-close"><span class="mdi mdi-close"></span></button>
</div>
<div class="reh-imsdb-url-row">
<input type="text" id="reh-imsdb-url" class="reh-imsdb-url-input" placeholder="Paste IMSDb or Scripts.com URL…" spellcheck="false" autocomplete="off">
<button type="button" class="btn-primary btn-sm" id="reh-imsdb-url-fetch"><span class="mdi mdi-download"></span> Fetch URL</button>
</div>
<div class="reh-imsdb-grid" id="reh-imsdb-grid">
<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Loading catalogue…</div>
</div>

View File

@ -320,6 +320,15 @@ details.helper .helper-body { padding: 12px 14px 14px; display: flex; gap: 10px;
#design-sample-text { min-height: 56px; }
audio { width: 100%; }
@media (min-width: 900px) {
#clone-sample-text, #lib-add-sample-text {
min-height: 190px;
font-size: 22px;
line-height: 1.65;
}
}
/* ── Voice Design ───────────────────────────────────────────────────────── */
.design-hints { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 12px; color: var(--subtext); }
.design-hints th, .design-hints td { text-align: left; vertical-align: top; padding: 7px 9px; border-bottom: 1px solid var(--border); }
@ -366,6 +375,20 @@ audio { width: 100%; }
.tryout-voice-pick { display: flex; gap: 6px; align-items: stretch; min-width: 0; }
.tryout-voice-pick select { flex: 1 1 auto; min-width: 0; }
.tryout-voice-pick .vp-root { flex: 1 1 auto; min-width: 0; } /* searchable widget replaces the select */
.conv-config-group .vp-root[data-vp-for="conv-tts-voice-select"] {
min-width: min(360px, 100%);
flex: 1 1 360px;
}
.conv-config-group .vp-root[data-vp-for="conv-tts-voice-select"] .vp-drop {
z-index: 120;
}
@media (max-width: 900px) {
.conv-config-group .vp-root[data-vp-for="conv-tts-voice-select"] {
width: 100%;
min-width: 0;
}
}
.tryout-voice-pick .btn-sm { flex: 0 0 auto; padding: 0 11px; display: inline-flex; align-items: center; }
@media (max-width: 720px) { .tryout-voice-row { grid-template-columns: 1fr; } }
@ -465,10 +488,11 @@ audio { width: 100%; }
@media (max-width:900px) { .stt-settings-grid { grid-template-columns:1fr; } }
/* ── Toast ──────────────────────────────────────────────────────────────── */
#toast { position: fixed; bottom: 24px; right: 24px; background: var(--surface); border: 1px solid var(--border); color: var(--text); padding: 14px 20px; border-radius: var(--radius); font-size: 14px; opacity: 0; transition: opacity .3s; pointer-events: none; z-index: 200; max-width: 380px; box-shadow: var(--shadow); }
#toast.show { opacity: 1; }
#toast { position: fixed; top: 50%; left: 50%; background: var(--surface); border: 2px solid var(--accent); border-left-width: 8px; color: var(--text); padding: 16px 20px; border-radius: 8px; font-size: 15px; font-weight: 700; opacity: 0; transform: translate(-50%, calc(-50% + 12px)) scale(.98); transition: opacity .18s, transform .18s; pointer-events: none; z-index: 5000; max-width: min(520px, calc(100vw - 40px)); min-width: min(360px, calc(100vw - 40px)); box-shadow: 0 20px 70px rgba(0,0,0,.35); text-align: center; }
#toast.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
#toast.success { border-color: var(--green); color: var(--green); }
#toast.error { border-color: var(--red); color: var(--red); }
#toast.info { border-color: var(--accent); color: var(--accent); }
/* ── Status bar ─────────────────────────────────────────────────────────── */
#status-bar { background: var(--surface); border-top: 1px solid var(--border); padding: 5px 18px; font-size: 12px; color: var(--subtext); min-height: 24px; flex-shrink: 0; }
@ -906,7 +930,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.section-search { width: 100% !important; max-width: 100%; box-sizing: border-box; }
/* Toast: full width on small screens */
#toast { right: 10px; left: 10px; max-width: none; }
#toast { left: 50%; right: auto; max-width: calc(100vw - 24px); min-width: calc(100vw - 24px); }
.toast-item { max-width: 100%; }
/* ── Voice workbench ──────────────────────────────────────────────────── */
@ -2394,6 +2418,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vp-trigger.open .vp-chevron { transform: rotate(180deg); }
.vp-avatar { flex-shrink: 0; }
.vp-flag { font-size: 13px; flex-shrink: 0; }
.vp-drop[hidden] { display: none !important; }
.vp-drop {
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 200;
background: var(--card-bg, var(--panel)); border: 1px solid var(--border);
@ -2583,8 +2608,11 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
justify-content: center; transition: background .15s, transform .1s, box-shadow .15s;
}
.conv-mic-btn:hover { background: #1d4ed8; }
.conv-mic-btn.recording { background: var(--red); border-color: var(--red); animation: convPulse 1s infinite; }
.conv-mic-btn.processing { background: var(--yellow); border-color: var(--yellow); cursor: default; }
.conv-mic-btn.live { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 4px rgba(37,99,235,.16); }
.conv-mic-btn.live:not(.processing) { animation: convPulse 1.4s infinite; }
.conv-mic-btn.recording:not(.live) { background: var(--red); border-color: var(--red); animation: convPulse 1s infinite; }
.conv-mic-btn.processing { background: var(--yellow); border-color: var(--yellow); cursor: pointer; }
.conv-mic-btn.processing.live { background: var(--accent); border-color: var(--accent); }
@keyframes convPulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.4); } 50% { box-shadow: 0 0 0 8px rgba(220,38,38,0); } }
.conv-mic-timer { font-size: 13px; font-variant-numeric: tabular-nums; color: var(--red); min-width: 36px; text-align: right; }
.conv-vad-label { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--subtext); cursor: pointer; white-space: nowrap; user-select: none; }
@ -2634,6 +2662,8 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.reh-imsdb-head h3 { margin: 0; font-size: 15px; display: flex; align-items: center; gap: 7px; white-space: nowrap; }
.reh-imsdb-url-row { display:flex; align-items:center; gap:8px; padding:10px 18px; border-bottom:1px solid var(--border); background:var(--panel); }
.reh-imsdb-url-row .reh-imsdb-url-input { flex:1; min-width:0; height:32px; padding:0 10px; border:1px solid var(--border); border-radius:7px; background:var(--surface); color:var(--text); font-size:13px; }
.reh-imsdb-search {
flex: 1; height: 34px; padding: 0 12px; border: 1px solid var(--border);
border-radius: 8px; background: var(--input-bg, var(--panel)); color: var(--text); font-size: 13px;
@ -2645,6 +2675,8 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
align-content: start;
}
.reh-imsdb-loading { grid-column: 1/-1; text-align: center; padding: 40px; color: var(--subtext); font-size: 13px; display: flex; align-items: center; justify-content: center; gap: 10px; }
.reh-imsdb-load-error { grid-column: 1/-1; margin-bottom: 8px; padding: 10px 12px; border: 1px solid var(--red); border-left-width: 5px; border-radius: 8px; color: var(--red); background: rgba(220,38,38,.08); font-size: 13px; font-weight: 700; }
.reh-scripts-link { white-space: nowrap; }
.reh-imsdb-card {
cursor: pointer; border-radius: 8px; overflow: hidden; background: var(--card-bg, var(--panel));
border: 1px solid var(--border); transition: transform .12s, box-shadow .12s, border-color .12s;
@ -2697,8 +2729,9 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
flex-direction: row; align-items: center; gap: 8px; padding: 0 12px; flex: 1; min-width: 0;
}
.reh-imsdb-grid.list-view .reh-imsdb-title {
-webkit-line-clamp: 1; font-size: 13px; flex: 1;
display:block; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text); -webkit-line-clamp: unset; font-size: 13px; flex: 1;
}
.reh-imsdb-grid.list-view .reh-imsdb-card { background:var(--surface); }
.reh-imsdb-grid.list-view .reh-imsdb-year { margin-left: auto; white-space: nowrap; font-size: 11px; }
/* View toggle buttons in modal header */
@ -2964,6 +2997,9 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.reh-cc-field { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.reh-cc-field > label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); }
.reh-cc-field select, .reh-cc-field input { width: 100%; min-width: 0; font-size: 12px; padding: 5px 7px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface); color: var(--text); font-family: var(--font); }
.reh-cc-voice .vp-root { width: 100%; min-width: 0; flex: 1 1 auto; }
.reh-cc-voice .vp-trigger { min-height: 30px; font-size: 12px; }
.reh-cc-voice .vp-drop { max-height: 310px; z-index: 80; }
.reh-cc-field select:focus, .reh-cc-field input:focus { outline: none; border-color: var(--accent); }
.reh-cc-soul { border-top: 1px solid var(--border); padding-top: 6px; }
.reh-cc-soul > summary { font-size: 11px; font-weight: 600; color: var(--subtext); cursor: pointer; display: flex; align-items: center; gap: 6px; list-style: none; }
@ -3011,10 +3047,14 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.reh-block-dialog { font-size: 15px; line-height: 1.65; padding-left: 40px; color: #1a1a2e; }
.reh-direction { font-style: italic; color: #666; font-size: 13px; padding: 4px 12px; }
.reh-char-img { vertical-align: middle; }
.reh-line-play-avatar { border:0; background:transparent; padding:0; margin:0 2px 0 0; cursor:pointer; border-radius:50%; position:relative; display:inline-flex; align-items:center; justify-content:center; }
.reh-line-play-avatar:hover { box-shadow:0 0 0 3px rgba(37,99,235,.18); }
.reh-line-play-avatar::after { content:'\F040A'; font-family:'Material Design Icons'; position:absolute; right:-5px; bottom:-5px; width:16px; height:16px; border-radius:50%; background:var(--accent); color:#fff; font-size:11px; display:flex; align-items:center; justify-content:center; box-shadow:0 1px 4px rgba(0,0,0,.24); }
.reh-line-active .reh-line-play-avatar::after { content:'\F03E4'; background:var(--red); }
/* Gutter: narrow play/range column left of each dialog block */
.reh-gutter-btn { width:16px; min-width:16px; flex-shrink:0; align-self:stretch; background:none; border:none; cursor:pointer; display:flex; align-items:center; justify-content:center; color:transparent; transition:color .12s; font-size:9px; padding:0; margin-right:4px; }
.reh-block:hover .reh-gutter-btn { color:var(--border); }
.reh-gutter-btn { width:22px; min-width:22px; flex-shrink:0; align-self:stretch; background:none; border:none; cursor:pointer; display:flex; align-items:center; justify-content:center; color:transparent; transition:color .12s; font-size:9px; padding:0; margin-right:4px; }
.reh-block:hover .reh-gutter-btn { color:var(--accent); }
.reh-range-start .reh-gutter-btn { color:#10b981 !important; }
.reh-range-end .reh-gutter-btn { color:#ef4444 !important; }
.reh-in-range { background:rgba(245,158,11,.05) !important; }
@ -3508,3 +3548,54 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
font-family: 'Courier New', monospace;
}
[data-theme="dark"] .reh-paper { background: #f8f8f2; }
/* Keep script-browser list rows readable even when old cached list styles collapse. */
.reh-imsdb-grid.list-view > .reh-imsdb-card {
height: 48px !important;
min-height: 48px !important;
max-height: 48px;
overflow: hidden;
}
.reh-imsdb-grid.list-view > .reh-imsdb-card .reh-imsdb-meta {
height: 48px;
min-height: 48px;
}
.reh-bulk-hint { font-size:11px; color:var(--subtext); margin-right:4px; }
.reh-bulk-mode .reh-selected {
background: rgba(37,99,235,.11) !important;
outline: 2px solid rgba(37,99,235,.35);
outline-offset: -2px;
}
.reh-bulk-mode .reh-selected .reh-block-dialog { font-weight:600; }
/* Keep script-browser thumbnail cards tall enough to click/download in cover view. */
.reh-imsdb-grid:not(.list-view) > .reh-imsdb-card {
display: flex !important;
flex-direction: column !important;
min-height: 230px !important;
height: auto !important;
overflow: hidden;
}
.reh-imsdb-grid:not(.list-view) > .reh-imsdb-card .reh-imsdb-poster-wrap {
display: block !important;
position: relative !important;
width: 100% !important;
aspect-ratio: 2 / 3 !important;
min-height: 178px !important;
height: auto !important;
padding-bottom: 0 !important;
flex: 0 0 auto !important;
}
.reh-imsdb-grid:not(.list-view) > .reh-imsdb-card .reh-imsdb-poster,
.reh-imsdb-grid:not(.list-view) > .reh-imsdb-card .reh-imsdb-fallback {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
}
.reh-imsdb-grid:not(.list-view) > .reh-imsdb-card .reh-imsdb-meta {
min-height: 46px;
flex: 0 0 auto;
}