Improve voice workflows and add user manual
This commit is contained in:
parent
40e42590cc
commit
9c2e507ccf
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
```
|
||||
|
||||
@ -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"}
|
||||
|
||||
|
||||
@ -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
714
docs/USER_MANUAL.md
Normal 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 |
|
||||
@ -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."},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -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>|«|«|‹)', 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()
|
||||
|
||||
@ -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 || [];
|
||||
|
||||
@ -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.3–0.8 s'],[_q(),'High'], [_m(),'4–6 GB VRAM']],
|
||||
'parakeet-asr': [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-TDT'],[_m(),'2 GB VRAM']],
|
||||
'parakeet-rnnt-nim': [[_t(),'~200× RT GPU'],[_l(),'<0.3 s'],[_q(),'Parakeet-1B'], [_m(),'2 GB VRAM']],
|
||||
'faster-whisper': [[_t(),'~70× RT GPU'],[_l(),'0.5–2 s'],[_q(),'Whisper large-v3'],[_m(),'~1.5 GB VRAM']],
|
||||
'fish-speech-api': [[_t(),'~0.4× GPU'],[_l(),'1–4 s'],[_q(),'Tone + clone'],[_m(),'~20 GB VRAM']],
|
||||
'vibevoice-msft-tts': [[_t(),'fast'],[_l(),'0.2–1 s'],[_q(),'TTS'],[_m(),'~2 GB']],
|
||||
'xtts-dgx-spark': [[_t(),'~0.3× GPU'],[_l(),'1–3 s'],[_q(),'XTTS v2'],[_m(),'~3 GB']],
|
||||
'litellm': [[_t(),'proxy'],[_l(),'route'],[_q(),'OpenAI API'],[_m(),'~600 MB']],
|
||||
'llama.cpp': [[_t(),'local'],[_l(),'model dependent'],[_q(),'OpenAI API'],[_m(),'model dependent']],
|
||||
'ollama': [[_t(),'local'],[_l(),'model dependent'],[_q(),'OpenAI API'],[_m(),'model dependent']],
|
||||
'llama-swap': [[_t(),'swap'],[_l(),'warm after load'],[_q(),'OpenAI API'],[_m(),'model dependent']],
|
||||
};
|
||||
const roleIcon = { tts: '<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>' };
|
||||
|
||||
|
||||
@ -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 character’s 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 won’t sound the same throughout.${suggest}`;
|
||||
const qwenHint = /qwen|voice design|custom/i.test((b.id || '') + ' ' + (b.label || '')) ? ' Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.' : '';
|
||||
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> gives strong tone but re-generates a fresh voice each line, so a character won’t sound the same throughout.${qwenHint}${suggest}`;
|
||||
warn.hidden = false;
|
||||
} else {
|
||||
warn.hidden = true;
|
||||
@ -3589,6 +3671,8 @@ $('reh-impex-md-btn')?.addEventListener('click', exportAsMd);
|
||||
const closeBtn = $('reh-imsdb-close');
|
||||
const coverBtn = $('reh-imsdb-cover-btn');
|
||||
const listBtn = $('reh-imsdb-list-btn');
|
||||
const urlInp = $('reh-imsdb-url');
|
||||
const urlBtn = $('reh-imsdb-url-fetch');
|
||||
if (!modal || !grid) return;
|
||||
|
||||
const CACHE_KEY = 'reh-imsdb-cat-v1';
|
||||
@ -3596,7 +3680,7 @@ $('reh-impex-md-btn')?.addEventListener('click', exportAsMd);
|
||||
|
||||
let _catalogue = null;
|
||||
let _posterObserver = null;
|
||||
let _view = localStorage.getItem('reh-imsdb-view') || 'cover'; // 'cover' | 'list'
|
||||
let _view = localStorage.getItem('reh-imsdb-view') || 'list'; // 'cover' | 'list'
|
||||
|
||||
function _applyView() {
|
||||
grid.classList.toggle('list-view', _view === 'list');
|
||||
@ -3672,29 +3756,38 @@ $('reh-impex-md-btn')?.addEventListener('click', exportAsMd);
|
||||
_applyView();
|
||||
}
|
||||
|
||||
async function importImsdb(it) {
|
||||
// In-modal loading indicator — IMSDb resolution can take a couple seconds
|
||||
// (detail page → real "Read Script" link → script page).
|
||||
grid.innerHTML = `<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();
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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’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’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">
|
||||
|
||||
@ -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 & 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>
|
||||
|
||||
107
static/style.css
107
static/style.css
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user