From 40e42590ccd6e4b37bd27bfa2e59a3428bc6c9e4 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 3 Jun 2026 14:23:35 +0200 Subject: [PATCH] Release v1.6.0: a11y (WCAG AA), i18n (DE), PWA, perf, tests, Cast UX Cast: card/list views, sort & filter, online voice picker, "Hear a line" sample button, AI character notes, import auto-save. Platform: WCAG 2.1 AA accessibility pass; German UI translation + language picker; installable PWA with offline shell; GZip + content-visibility virtualization + lazy images + Rehearser PCM memory cap (mobile stability); Playwright suite (desktop + iPhone); opt-in minified bundle build. Fixes: screenplay parser false characters; Fish-Speech inline-tag tones; narrator/voice pickers list full library; clone GUI rework; fish.audio import dedup; voice-ID rename; bulk-delete modal. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 15 + CHANGELOG.md | 75 + Dockerfile | 1 + VERSION | 2 +- core/config.py | 14 +- core/constants.py | 1 + core/tts_helpers.py | 89 +- docs/ARCHITECTURE.md | 43 + package.json | 17 + playwright.config.js | 29 + portainer-stack.yml | 10 +- routes/admin.py | 26 +- routes/conversation.py | 266 +- routes/docker.py | 2 +- routes/library.py | 24 +- routes/sources.py | 331 ++- routes/tts.py | 22 +- scripts/minify.mjs | 42 + server.py | 13 +- static/icon.svg | 9 + static/index.html | 77 +- static/js/ai-backends.js | 93 +- static/js/conversation.js | 7 +- static/js/fishaudio-browser.js | 266 ++ static/js/generation.js | 26 +- static/js/i18n.js | 126 + static/js/rehearser-parse.js | 172 ++ static/js/rehearser.js | 4016 ++++++++++++++++++++++++--- static/js/routing.js | 10 +- static/js/settings.js | 2 +- static/js/tts-preview.js | 20 +- static/js/utils.js | 17 + static/js/voice-clone.js | 134 +- static/js/voice-inspector.js | 21 +- static/js/voice-library.js | 418 ++- static/js/voice-picker.js | 224 ++ static/js/voice-sources.js | 17 +- static/loader.js | 23 +- static/manifest.webmanifest | 14 + static/nav.js | 135 +- static/sections/s-clone.html | 226 +- static/sections/s-conversation.html | 10 +- static/sections/s-design.html | 6 +- static/sections/s-llms.html | 129 +- static/sections/s-rehearser.html | 517 +++- static/sections/s-settings.html | 2 +- static/sections/s-studio.html | 113 +- static/sections/s-tryout.html | 28 +- static/sections/s-voices.html | 22 +- static/style.css | 1190 +++++++- static/sw.js | 38 + tests/bundle.spec.js | 34 + tests/i18n.spec.js | 21 + tests/rehearser.spec.js | 52 + tests/smoke.spec.js | 61 + 55 files changed, 8577 insertions(+), 721 deletions(-) create mode 100644 docs/ARCHITECTURE.md create mode 100644 package.json create mode 100644 playwright.config.js create mode 100644 scripts/minify.mjs create mode 100644 static/icon.svg create mode 100644 static/js/fishaudio-browser.js create mode 100644 static/js/i18n.js create mode 100644 static/js/rehearser-parse.js create mode 100644 static/js/voice-picker.js create mode 100644 static/manifest.webmanifest create mode 100644 static/sw.js create mode 100644 tests/bundle.spec.js create mode 100644 tests/i18n.spec.js create mode 100644 tests/rehearser.spec.js create mode 100644 tests/smoke.spec.js diff --git a/.gitignore b/.gitignore index 66298e3..275cf1c 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,18 @@ config/voice_design_presets.json config/tts_routes.json logs/*.log logs/*.log.* + +# Node / Playwright test tooling +node_modules/ +package-lock.json +test-results/ +playwright-report/ +.playwright/ + +# Generated production bundle (build with: npm run minify) +static/dist/ + +# Local caches / scratch / recovery artifacts +config/fishaudio_cache/ +claude studio/ +*.recovered-*.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 918651d..e436f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,81 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.6.0] — 2026-06-03 + +### Added +- **Script Rehearser — Cast overhaul**: Card / List **view toggle**; **sort & filter** (name, gender, language, line count, tag); character-card-game styling (large portrait, name, description line, action row); **per-character online voice picker** (audition the match, browse alternatives, pick from your library, or search fish.audio inline); **"Hear a line"** button that synthesizes a representative one-liner from the character's own dialogue in their assigned voice. +- **AI character notes** — Match local / Match online / Design all now research the play and drop a per-character note (description, gender, speaking style). +- **Rehearser import auto-save** — uploading a script (PDF/text/FDX/Fountain) saves it to the Library immediately. +- **Internationalization (i18n)** — interface **language picker** with **German** translation of the UI chrome; English is the source language (`static/js/i18n.js`, extend via `I18N_DICT`). +- **Progressive Web App** — installable with offline app shell (`manifest.webmanifest` + network-first service worker), iOS web-app meta and safe-area support. +- **Test suite** — Playwright smoke + functional tests (desktop **and** iPhone/WebKit profiles): app load, sections, clone tabs, PWA, rehearser parse→cast, bundle, i18n. +- **Build tooling** — opt-in single minified bundle (`npm run minify` → `static/dist/main.min.js`, loaded when `?bundle=1`); architecture & migration notes in `docs/ARCHITECTURE.md`. + +### Changed +- **Accessibility → WCAG 2.1 AA** — accessible names on all controls, AA text/badge/button contrast, keyboard-focusable scroll regions (audited with axe-core; 40+ violations → a handful of edge cases). +- **Performance / mobile stability** — GZip responses; `content-visibility` virtualization for long lists; lazy-loaded images; Rehearser caps decoded-PCM memory to a sliding window (fixes iPhone crashes); leaked `AudioContext` closed; bounded-concurrency bulk operations. +- **Clone a Voice — reworked GUI** — integrated tab strip (Microphone · Upload · URL/YouTube), clearer sections, scroll-to + obvious "transcribing…" feedback, sample sentence keeps the typed name across language switches. +- **Fish-Speech tone** — per-line tones now reach OpenAudio S2 via inline `[tag]` markers in the text (the `instruct` field is ignored by S2). +- **fish.audio import** — de-duplicates voices already in the library and diversifies matches so different characters don't all get the same fallback voice. +- **Get Voices Online** — tabbed, integrated source switcher; the scrape box lives only under "Direct sources". +- **Voice library** — editable Voice ID (rename), complete country/accent list (decoupled from language), always-visible **Select all** toggle, redesigned bulk-delete confirmation modal. + +### Fixed +- **Screenplay parser** — title-page text, numbered scene headings (`A1 EXT. … EVENINGA1`), `OMITTED`/`CONTINUED` markers and dated page slugs are no longer detected as characters. +- **Narrator & all voice pickers** now list the full voice library (lazy-loaded if needed). +- **Cast list controls** wire reliably regardless of when the section mounts; role names no longer truncate; avatars enlarged. + +--- + +## [1.5.0] — 2026-06-01 + +### Added +- **Fish-Speech TTS backend** — clones a voice's saved reference WAV (consistent identity) **and** honours inline emotion markers like `(angry)`, `(whispering)`, `(excited)` per line. The only backend that is both WAV-anchored and style-aware; the Rehearser prefers it when available. Configurable via `FISHSPEECH_URL`. +- **Fish.audio Voice Library browser** (Get Voices Online) — search/filter the ~2M public voices at `api.fish.audio`, preview samples, and one-click **Import** (MP3 → WAV + reference transcript) → an instantly clonable voice. +- **Cast tab redesigned as character cards** — big avatar, name, language, gender, tags, voice picker, voice-design prompt, "Character soul · LLM brief" with **Develop** (LLM), and per-character **Ignore / Hide / Delete**. +- **Bulk-edit lines on the Stage** — a **Select** mode adds per-line checkboxes: **Ignore**, **Hide**, **Delete**, Un-ignore, Show-hidden. +- **Designed voices** — display name is the **character name**, the script becomes a **tag**, and an auto-picked **gender/type avatar icon** replaces the language flag. +- **Clone a Voice — name-first flow** — name first (drops into the read-aloud sentence), live voice-ID, **auto-transcribe** after trim, **auto-save when ready**, and a **File / URL / Microphone source picker**. + +### Changed +- **IMSDb scraper** — resolves the real script via each title's detail-page "Read Script" link instead of guessing a slug. +- **Rehearser default backend** — `voice_clone` (then `fishspeech`) for consistent identity; the tone-warning explains the trade-off both ways. +- **Try It Out** — the cramped voice/backend row is now a clean responsive layout. + +### Fixed +- **Narrator was silent** — `narratorVoice` now stays in sync with the narrator cast row. +- **About → Changelog was empty** — `CHANGELOG.md` is now shipped in the image and resolved resiliently. + +--- + +## [1.4.0] — 2026-06-01 + +### Added +- **IMSDb browser — list / cover view toggle** — switch between poster-grid and compact list view; preference persisted in `localStorage`. +- **IMSDb browser — local catalogue cache** — catalogue is cached in `localStorage` for 6 h (matching server cache), making reopening the browser instant. +- **IMSDb browser — title in fallback** — script title shown on each gradient poster card while the real poster loads. +- **IMSDb browser — loading spinner** — animated spinner while the catalogue fetches. +- **IMSDb browse button on Import / Export tab** — the "Browse IMSDb" button is now also available on the Import / Export panel; modal moved to global scope. +- **Auto-design — detailed progress panel** — each character shows an expandable card during voice design: gender chip, language, voice ID, age, and the full LLM-generated character description with a live spinner. +- **Auto-design — script title as voice tag** — designed voices receive the script title as their `tag` value so they're easy to filter/find. +- **Auto-design — LLM endpoint datalist** — the LLM endpoint field is now backed by a `` auto-populated from all configured Language Models engines, plus hardcoded defaults (Ollama, vLLM, LM Studio, llama-swap, LiteLLM). +- **Stage — synthesis progress** — the synth bar is now more prominent (gradient fill, spinner, sticky), each synthesising line pulses with a blue glow, and the page auto-scrolls to the active line. +- **Stage — tone warning banner** — when a non-style-aware backend (voice_clone, streaming, NVIDIA) is selected and tone is set on lines, a dismissable amber warning banner names the backend and suggests a style-aware alternative. +- **Bulk-edit tools** — new sticky toolbar in My Voices: select any number of voices with checkboxes, then: **Set tag**, **Hide**, **Unhide**, **Rate**, or **Delete** in one action. +- **Rehearser — Voice Design default** — the TTS backend picker in Cast now defaults to `voice_design` (style-aware) instead of voice_clone, so tone selections work out of the box. + +### Changed +- **Stage — edit button moved to right gutter** — the pencil (edit text) button is now stacked with the note button in the right-side gutter of each dialog block, keeping the block header clean. +- **Tone / instruct order** — when an emotion is set on a line, the instruction now leads with a directive (`"Speak in a manner. "`) so the model prioritises the tone over the base identity description. +- **My Voices — hidden voices in sub-tabs** — fixed: Cloned, Designed, and Favorites tabs now respect the "Disabled" checkbox filter; hidden voices no longer appear unless explicitly requested. + +### Fixed +- **IMSDb covers showing as flat lines** — replaced `aspect-ratio` on a flex child (unreliable in all major browsers) with the `padding-bottom: 150%` wrapper trick, guaranteeing a correct 2:3 poster ratio. +- **Rehearser TTS backend "No backend available"** — `refreshRehBackends` now triggers the global backend probe if `_ttsBackends` is empty, and registers a `_ttsRefreshHook` so the select stays in sync with the Engines page. + +--- + ## [1.3.0] — 2026-05-31 ### Added diff --git a/Dockerfile b/Dockerfile index 5deadb8..0b33377 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ RUN pip install --no-cache-dir -r /tmp/requirements.txt # ── Application ─────────────────────────────────────────────────────────────── WORKDIR /app COPY server.py . +COPY CHANGELOG.md . COPY core/ core/ COPY routes/ routes/ COPY static/ static/ diff --git a/VERSION b/VERSION index f0bb29e..dc1e644 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.0 +1.6.0 diff --git a/core/config.py b/core/config.py index 4d4c40a..0a96d60 100644 --- a/core/config.py +++ b/core/config.py @@ -15,7 +15,7 @@ from core.constants import ( _NVIDIA_ROUTER_DEFAULT, _NVIDIA_TTS_DEFAULT, _NVIDIA_ASR_DEFAULT, _NVIDIA_CLONE_DEFAULT, _NVIDIA_ZEROSHOT_DEFAULT, _NVIDIA_FLOW_DEFAULT, _FASTER_WHISPER_DEFAULT, _WHISPER_CPP_DEFAULT, - _KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT, + _KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT, _FISHSPEECH_DEFAULT, _TTS_STREAM_DEFAULT, ) @@ -35,7 +35,7 @@ _SETTINGS_KEYS = { "refine_fillers", "refine_repetitions", "refine_corrections", "refine_punctuation", "captures_default_voice", "client_voice_bindings", - "llm_url", + "llm_url", "llm_model", # Browser-persistent UI state "engine_local_urls", "engine_container_names", "custom_engine_cards", "refine_llm_url", "conv_llm_url", @@ -120,9 +120,13 @@ def _clean_preview_backend(value: str) -> str: "xtts_v2": "xtts", "xtts2": "xtts", "coqui_xtts": "xtts", + "fish": "fishspeech", + "fish_speech": "fishspeech", + "fishaudio": "fishspeech", + "openaudio": "fishspeech", } key = aliases.get(key, key) - return key if key in {"voice_clone", "streaming", "customvoice", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice", "xtts"} else "voice_clone" + return key if key in {"voice_clone", "streaming", "customvoice", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice", "xtts", "fishspeech"} else "voice_clone" def _tts_extra_params(settings: dict, backend: str = "voice_clone") -> dict: @@ -188,6 +192,8 @@ def _preview_backend_base_url(settings: dict, backend: str) -> str: return settings.get("vibevoice_url") or _VIBEVOICE_DEFAULT if backend == "xtts": return settings.get("xtts_url") or _XTTS_DEFAULT + if backend == "fishspeech": + return settings.get("fishspeech_url") or _FISHSPEECH_DEFAULT return settings.get("tts_url") or _TTS_DEFAULT @@ -242,6 +248,7 @@ def _load_settings() -> dict: "kokoro_url": _KOKORO_DEFAULT, "vibevoice_url": _VIBEVOICE_DEFAULT, "xtts_url": _XTTS_DEFAULT, + "fishspeech_url": _FISHSPEECH_DEFAULT, "whisper_api_key": "", "tts_api_key": "", "voice_design_api_key": "", @@ -260,6 +267,7 @@ def _load_settings() -> dict: "captures_default_voice": "", "client_voice_bindings": {}, "llm_url": "http://localhost:11434/v1", + "llm_model": "", "engine_local_urls": {}, "engine_container_names": {}, "custom_engine_cards": [], diff --git a/core/constants.py b/core/constants.py index 5b2b705..5e418a0 100644 --- a/core/constants.py +++ b/core/constants.py @@ -39,6 +39,7 @@ _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") +_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 _VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign") diff --git a/core/tts_helpers.py b/core/tts_helpers.py index 4201069..d960675 100644 --- a/core/tts_helpers.py +++ b/core/tts_helpers.py @@ -26,13 +26,28 @@ from core.audio import _to_wav_24k, _duration as _dur from core.voice import _find_voice_audio, _read_reference_text -# ── NVIDIA language codes ───────────────────────────────────────────────────── +# ── Language helpers ────────────────────────────────────────────────────────── _NVIDIA_LANGUAGE_CODES = { "EN": "en-US", "DE": "de-DE", "ES": "es-ES", "FR": "fr-FR", "IT": "it-IT", "PT": "pt-PT", "NL": "nl-NL", "PL": "pl-PL", } +# Maps 2-letter prefix from voice ID (e.g. "DE_M_…") to full language name +# used by the faster-qwen3-tts clone server's `language` field. +_VOICE_LANG_NAMES = { + "EN": "English", "DE": "German", "ES": "Spanish", "FR": "French", + "IT": "Italian", "PT": "Portuguese", "NL": "Dutch", "PL": "Polish", + "ZH": "Chinese", "JA": "Japanese", "KO": "Korean", "RU": "Russian", + "AR": "Arabic", "HI": "Hindi", "TR": "Turkish", "SV": "Swedish", +} + + +def _voice_language_name(voice: str) -> str: + """Return the full language name inferred from the voice ID prefix (e.g. DE_M_ → German).""" + prefix = str(voice or "").split("_", 1)[0].upper() + return _VOICE_LANG_NAMES.get(prefix, "") + def _nvidia_clone_language_code(voice: str, language: str = "") -> str: raw = str(language or "").strip() @@ -104,7 +119,15 @@ def _tts_request_config( endpoint, payload = f"{tts_url}/v1/audio/speech", {"model": "tts-1", "input": text, "voice": voice, "response_format": response_format} if instruct.strip(): payload["instruct"] = instruct.strip() - _apply_tts_extra_params(payload, settings, extra_backend or backend_override or "voice_clone") + # Include language derived from voice ID prefix so the TTS server knows what + # language to synthesise (e.g. DE_M_… → German). Only set when the backend + # is the local faster-qwen3-tts clone server — skip for OpenAI / cloud APIs. + _eff_backend = extra_backend or backend_override or "voice_clone" + if _eff_backend in ("voice_clone", "streaming", "customvoice"): + lang = _voice_language_name(voice) + if lang: + payload["language"] = lang + _apply_tts_extra_params(payload, settings, _eff_backend) return endpoint, payload, tts_hdrs @@ -320,6 +343,66 @@ def _nvidia_clone_request_audio( return audio, media_type +# ── Fish-Speech request (clone from saved WAV + inline emotion markers) ────── + +def _fishspeech_emotion_prefix(instruct: str) -> str: + """Turn the per-line style instruction into a Fish-Speech inline emotion marker. + + The Rehearser sends ``"Speak in a {emotion} manner. {persona}"`` — the persona is + already carried by the cloned reference WAV, so we only forward the emotion as a + ``(emotion)`` tag, which Fish-Speech honours for per-line tone control. + """ + import re + s = (instruct or "").strip() + if not s: + return "" + m = re.search(r"speak(?:ing)?\s+in\s+(?:a|an)\s+([a-z\- ]+?)\s+manner", s, re.I) + if m: + return f"({m.group(1).strip().lower()}) " + return f"({s}) " if len(s) <= 40 else "" + + +def _fishspeech_request_audio( + text: str, + voice: str, + settings: dict, + instruct: str = "", + language: str = "", +) -> tuple[bytes, str]: + """Synthesize via Fish-Speech: clone the voice's saved reference WAV (consistent + speaker identity) while applying the line's tone as an inline emotion marker.""" + import base64 + import hashlib + from core.validation import _validate_http_url + base_url = _validate_http_url(_preview_backend_base_url(settings, "fishspeech"), allow_private=True).rstrip("/") + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + audio_path = _find_voice_audio(voice, scan_dir) + if audio_path is None: + raise RuntimeError(f"reference voice not found in library: {voice}") + wav = _to_wav_24k(audio_path) + _has_ref, ref_text = _read_reference_text(audio_path) + audio_b64 = base64.b64encode(wav.read_bytes()).decode("ascii") + # Stable per-voice seed → reduces run-to-run drift on top of the reference clone. + seed = int(hashlib.md5(voice.encode("utf-8")).hexdigest()[:8], 16) + + payload = { + "text": _fishspeech_emotion_prefix(instruct) + text, + "format": "wav", + "references": [{"audio": audio_b64, "text": ref_text or ""}], + "seed": seed, + "use_memory_cache": "on", + "chunk_length": 200, + "normalize": True, + } + resp = requests.post(f"{base_url}/v1/tts", json=payload, timeout=180) + resp.raise_for_status() + audio = resp.content + if not audio or len(audio) < 256: + raise RuntimeError("Fish-Speech returned empty audio") + media_type = resp.headers.get("content-type", "audio/wav").split(";", 1)[0] or "audio/wav" + return audio, media_type + + # ── VibeVoice request ───────────────────────────────────────────────────────── def _vibevoice_request_audio(text: str, settings: dict) -> tuple[bytes, str]: @@ -381,6 +464,8 @@ def _preview_request_audio( ) if backend == "vibevoice": return _vibevoice_request_audio(text, settings) + if backend == "fishspeech": + return _fishspeech_request_audio(text, voice, settings, instruct) if backend == "xtts": return _tts_request_audio( text, voice, settings, instruct, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..b4505e5 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,43 @@ +# Architecture & optimization notes + +## Current shape +- **Backend**: FastAPI, modular — `server.py` (app + middleware) → `routes/*.py` (admin, tts, library, sources, stt, conversation, docker, settings) → `core/*.py` (audio, voice, config, validation, tts_helpers, …). +- **Frontend**: ~15k lines of **plain global-scope JS** in `static/js/*.js`, loaded in order by `static/loader.js` (no bundler/build step). Sections are HTML fragments in `static/sections/*.html` fetched at runtime. Modules communicate via globals (`window._voices`, `$`, `escHtml`, `toast`, `saveMeta`, `loadVoiceLibrary`, …) and some runtime monkey-patching (e.g. `renderVoiceList` is reassigned in `voice-library.js`). + +## Already optimised +- **GZip** responses (`server.py`) — ~75% smaller text transfer. +- **Static caching** middleware (immutable for versioned JS/CSS). +- **Native virtualization**: `content-visibility:auto` on long-list rows (voice library, online cards, cast cards). +- **Lazy images** (`loading="lazy" decoding="async"`) across all big lists. +- **Rehearser memory cap**: decoded PCM is kept to a sliding window (`REH_DECODE_WINDOW`) around the playhead. +- **AudioContext** instances are closed (iOS limit). +- **PWA**: `manifest.webmanifest` + network-first `sw.js` (served from root for `/` scope), installable, offline shell. +- **Concurrency pool** (`runPool` in `utils.js`) for bulk network ops. +- **Smoke tests**: Playwright (`tests/`, desktop + iPhone profiles). + +## Build tooling (opt-in) — DONE +`npm run minify` → `scripts/minify.mjs` concatenates the feature modules **in loader order** into `static/dist/main.min.js` and minifies whitespace/syntax only (**identifiers kept** — inline HTML `onclick="navTo(...)"` and cross-file refs rely on global names). `loader.js` loads this single bundle when `window.APP_USE_BUNDLE === true` (or `?bundle=1`), else falls back to per-file loading — so editing `static/js/*` stays live by default. Verified end-to-end by `tests/bundle.spec.js`. Rebuild the bundle after JS edits if you ship with it enabled. + +## Staged migration plan (the two big refactors — do incrementally, behind the smoke tests) + +### 1. Decouple globals → ES modules +The global scope + monkey-patching is the root cause of the recurring "wiring/timing" bugs (controls bound before the section mounts, etc.). Migrate **one file at a time**: +1. Add `"type": "module"` loading path; keep a compat shim that re-exposes the few cross-module symbols on `window` during transition. +2. Convert leaf modules first (`utils`, `settings`, `voice-picker`), exporting explicitly and importing where used. +3. Replace the `renderVoiceList` monkey-patch with a normal function + an `onRender` hook. +4. Switch `loader.js` to a single ` @@ -91,7 +107,7 @@
-