diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e6b6fc --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copy this file to .env and fill in the values you need. .env itself is +# gitignored - never commit it. + +# Folder of an unzipped Vosk speech-recognition model (download one from +# https://alphacephei.com/vosk/models, e.g. vosk-model-small-en-us-0.15), +# used by the Voice Reference dock's "Vosk" auto-transcription engine +# (kokoro_gui/engine/asr.py). Leave unset if you're only using the default +# Audio8-ASR-0.1B engine. You can also set/edit this from the dock itself +# (Browse/Save/Reload next to the Vosk model field) instead of by hand. +VOSK_MODEL_PATH= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..61a57ec --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +# Normalize line endings: LF in the repo and on checkout, on every OS. +* text=auto eol=lf + +# Windows launcher needs CRLF for cmd.exe. +*.bat text eol=crlf + +# Binary assets, never touched by the text filters. +*.png binary +*.jpg binary +*.gif binary +*.wav binary +*.flac binary +*.mp3 binary +*.ogg binary +*.pt binary +*.tbaw binary diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..ce6bcf1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,80 @@ +name: Bug report +description: Something crashed, sounded wrong, or didn't do what the docs say. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Installation questions and "how do I" belong in Discussions. This form is for things that are broken. + - type: input + id: version + attributes: + label: KokoroGUI version + description: The "New in" heading at the top of the README, or `kokoro_gui/__init__.py`'s `APP_VERSION`. + placeholder: "4.0.0" + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + options: + - Windows + - Linux + - macOS + validations: + required: true + - type: input + id: python + attributes: + label: Python version + placeholder: "3.11.9 (python --version)" + validations: + required: true + - type: dropdown + id: engine + attributes: + label: Engine (Options > Engine) + options: + - Kokoro + - Audio8 + - Dummy + - Not engine-related + validations: + required: true + - type: input + id: device + attributes: + label: Device + description: Options > Device, plus the GPU if any. + placeholder: "cuda, RTX 3060 / cpu" + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: What you clicked or typed, in order. If a specific text triggers it, paste the text (or the smallest part that still does). + placeholder: | + 1. New project, paste "[Narrator]: Hello." + 2. Generate + 3. ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: What you expected, and what happened instead + validations: + required: true + - type: textarea + id: traceback + attributes: + label: Traceback or log + description: The terminal output from `python main.py`, if any. Pasted as text, not a screenshot. + render: text + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I ran `pip install -r requirements.txt` on this version before reporting. + - label: The `.tbaw` or text that triggers this is something I can share if asked. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..113136e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Questions and help + url: https://github.com/CoffeeMethod/KokoroGUI/discussions + about: Install trouble, "how do I", model and hardware questions. + - name: Security issue + url: https://github.com/CoffeeMethod/KokoroGUI/security/advisories/new + about: Report privately. See SECURITY.md. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..3232f9a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Something the app should do that it doesn't. +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: What are you trying to do? + description: The task, not the button. "Make a two-voice audiobook from an EPUB with chapter breaks" tells us more than "add a chapter button". + validations: + required: true + - type: textarea + id: today + attributes: + label: How do you do it today? + description: The workaround, or "can't". + - type: textarea + id: proposal + attributes: + label: What would you like instead? + description: Optional. If you have a specific design in mind, describe it; if not, the first two answers are enough. + - type: dropdown + id: engine + attributes: + label: Engine, if it matters + options: + - Any + - Kokoro + - Audio8 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d945853 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +## What + + + +## Why + + + +## How to check it + + + +## Checklist + +- [ ] `pytest` passes locally (the fast suite; CI runs it on Windows and Linux) +- [ ] New settings are threaded through `_assemble_config` and `tests/gui_qt/test_qt_config_assembly.py` +- [ ] README's Features list and "New in" section updated if a user can see the change diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c984fc6..672eb24 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,14 +5,24 @@ on: branches: [main] pull_request: +# The token only ever reads the checkout; nothing here pushes or comments. +permissions: + contents: read + +# A new push to the same branch/PR cancels the run it supersedes. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: - # Playback goes through playback.py (sounddevice/PortAudio) instead of - # the Windows-only `winsound` module, so kokoro_engine.py/gui.py no - # longer force Windows-only. ubuntu-latest additionally needs: - # - libportaudio2 (system PortAudio lib `sounddevice` dlopens) - # - Xvfb (the GUI suite builds real Tk windows - tests/conftest.py's - # `tts_app` fixture - which needs a display on headless Linux) + # CI runs the engine, DAW model and audio suites only. tests/gui_qt/ + # (real QtTTSApp widgets, docks, screenshots) is skipped here and run + # locally with a plain `pytest`; it was the flaky half of every failed + # run. Nothing collected below imports PySide6.QtGui or QtWidgets + # (tests/test_mixer_transport.py needs QtCore only), so the Linux leg + # needs no Qt runtime libraries and no QT_QPA_PLATFORM. It still needs + # libportaudio2, the system PortAudio library `sounddevice` dlopens. # macos-latest is left out for now (unverified) - see ROADMAP.md's # "CI expansion" item. strategy: @@ -26,26 +36,34 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.11" + # torch alone is several hundred MB; keyed on both requirements files. + cache: pip + cache-dependency-path: | + requirements.txt + requirements-test.txt - - name: Install PortAudio + Xvfb (Linux) + - name: Install PortAudio (Linux) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libportaudio2 xvfb + run: | + sudo apt-get update + sudo apt-get install -y libportaudio2 - name: Install dependencies run: | pip install -r requirements.txt pip install -r requirements-test.txt - - name: Run fast test suite (Linux) - if: runner.os == 'Linux' - run: xvfb-run -a pytest - # Runs the mocked-pipeline suite only (pytest.ini already sets - # `-m "not integration"` by default). No eSpeak NG or model - # download needed. The real-synthesis integration suite + - name: Run non-GUI test suite + # -X faulthandler from interpreter start: pytest's own faulthandler + # only switches on at pytest_configure, after tests/conftest.py + # (and torch, pedalboard with it) has already been imported, so a + # native crash during those imports printed nothing. + # --ignore=tests/gui_qt leaves the Qt suite out (its conftest builds + # the whole app). -p no:pytest-qt keeps the pytest-qt plugin from + # importing QtGui/QtWidgets at configure time, which it does even + # when no test uses qtbot. + run: python -X faulthandler -m pytest --ignore=tests/gui_qt -p no:pytest-qt + # pytest.ini already deselects `integration` and `slow`. No eSpeak + # NG or model download needed. The real-synthesis integration suite # (`pytest -m integration tests/integration`) is intentionally - # left out of CI - it's slow and pulls model weights. Wrapped in - # xvfb-run so the Tk-based GUI tests have a display to attach to. - - - name: Run fast test suite (Windows) - if: runner.os != 'Linux' - run: pytest + # left out of CI - it's slow and pulls model weights. diff --git a/.gitignore b/.gitignore index eb6e99e..289d455 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,14 @@ cython_debug/ /ROADMAP.md /CLAUDE.md /tests/output/ +/custom_voices/ +/cache/ +/presets/ +/Claude/PLAN_qt_and_engine_abstraction.md +/generation_stats.json +/SECURITY_AUDIT.md +/Claude/ +/.claude/ +document.json +document.tbaw +config_qt.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..52c4095 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing + +Bug reports and pull requests are welcome. Questions go in Discussions, security reports through +the Security tab (see [SECURITY.md](SECURITY.md)). + +## Setup + +```bash +git clone https://github.com/CoffeeMethod/KokoroGUI.git +cd KokoroGUI +python -m venv .venv && . .venv/Scripts/activate # or .venv/bin/activate +pip install -r requirements.txt -r requirements-test.txt +python main.py +``` + +Python 3.11 or newer. eSpeak NG is only needed to actually synthesize with Kokoro; the fast test +suite runs without it. + +## Tests + +```bash +pytest +``` + +That's the fast suite: the Kokoro pipeline is mocked, playback is mocked, no model download, runs +in well under a minute. The Qt tests run headless (`tests/gui_qt/conftest.py` sets +`QT_QPA_PLATFORM=offscreen`), so no display is needed. CI runs the suite on `windows-latest` and +`ubuntu-latest` without `tests/gui_qt/` (`pytest --ignore=tests/gui_qt -p no:pytest-qt`), so the +GUI tests only run on your machine. Run plain `pytest` before you push. + +Two conventions the suite enforces, both from `tests/conftest.py`: + +- Test configs come from the `make_config` fixture and have `caching: False`. Only + `tests/test_caching.py` turns caching on; `tests/test_meta_caching_policy.py` fails the run if + another file does. +- Tests never touch the real `custom_voices/` or `cache/` directories or a real audio device. Use + the `isolated_dirs`, `engine` and `fake_pipeline` fixtures rather than patching around them. + +GUI tests build a real `QtTTSApp` through the `qt_app` fixture in `tests/gui_qt/conftest.py`, +with the engine replaced by `StubEngine`. Save and Open run on a thread; call +`qt_app.wait_for_project_io()` before asserting on the result. + +The integration suite (`pytest -m integration tests/integration -s`) does real synthesis and is +opt-in. It isn't run in CI. + +## Pull requests + +- Branch from `main`, one change per PR. +- `pytest` green locally before you push (that includes `tests/gui_qt/`, which CI skips). CI has + to pass on both OSes to merge. +- A new setting is threaded through `QtTTSApp._assemble_config` and covered in + `tests/gui_qt/test_qt_config_assembly.py`. +- If a user can see the change, update the README: the Features list, and a bullet under the + current "New in" heading. +- No formatter or linter is configured. Match the style of the file you're in. + +## Layout + +- `kokoro_engine.py` and `kokoro_gui/engine/` are the synthesis core (mixins per feature area). +- `kokoro_gui/engines/` is the backend interface and the three registered backends (Kokoro, + Audio8, Dummy). Nothing there imports the document model. +- `kokoro_gui/daw/` is the document model: text, clips, tracks, characters, arrangement, dirty + tracking, undo. +- `kokoro_gui/audio/` is the transport, mixer and read-time FX stage. +- `kokoro_gui/qt/` is the PySide6 shell; every panel is a dock under `kokoro_gui/qt/docks/`. +- `docs/` is the GitHub Pages site. diff --git a/README.md b/README.md index 6ca8e1e..6cdd422 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,221 @@ # Kokoro TTS GUI -A modern, high-quality Text-to-Speech (TTS) application built with Python, featuring a user-friendly graphical interface and powered by the [Kokoro](https://github.com/hexgrad/kokoro) library. +[![Tests](https://github.com/CoffeeMethod/KokoroGUI/actions/workflows/tests.yml/badge.svg)](https://github.com/CoffeeMethod/KokoroGUI/actions/workflows/tests.yml) +[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) +[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) -Screenshot 2026-02-13 172822 +A desktop text-to-speech app built with Python: a dockable PySide6 (Qt) interface over a pluggable +synthesis backend, edited less like a form and more like a DAW project, with a document of text, +timed clips on character tracks, and undo/redo. Powered by [Kokoro](https://github.com/hexgrad/kokoro) +by default, with a zero-shot voice-cloning backend also built in. +KokoroGUI 4.0: transcript and settings tabs over a seconds-axis timeline and transport, dark theme -(demo sounds better in `.wav` but GitHub dosent suport that so its kinda bad) +*(demo sounds better in `.wav` but GitHub doesn't support that so it's kinda bad)* https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e -## New in 3.2.0 +## New in Beta 4.0.0 -- **Cross-Platform Audio Playback:** Preview and JIT playback now go through `sounddevice`/`soundfile` instead of the Windows-only `winsound` module, removing a hard Windows dependency from `kokoro_engine.py`/`gui.py`. +The rebuild. 3.2.0 was a CustomTkinter form over one `kokoro_engine.py`; 4.0.0 is a PySide6 +shell over a document with clips, tracks and characters, a pluggable engine layer with a second +real backend, and projects that live in one file. + +- **Qt frontend, the only frontend.** `python main.py`/`run.bat` launches a PySide6 shell of + dockable panels (`kokoro_gui/qt/`) in a 2x2 grid: Transcript | Settings / Audio FX / Lexicon / + Voices tabs on top, Timeline | Transport underneath, under File / Edit / Options / Workspace + menus. Every panel is a dock you can drag; **Workspace > Advanced / Simple / Reset layout** are + saved layouts (Simple hides the timeline and gives the transcript the full height). The + CustomTkinter app (`gui.py`) is gone; PySide6 is a regular dependency in `requirements.txt`. +- **A document, not a text box.** The old "generate this text" input is a project: a `Document` + of canonical text with `Clip`/`Track`/`Character` metadata layered on top (`kokoro_gui/daw/`). + The transcript is the source of truth and generated audio is a render of it, tracked per clip + with hash-based dirty detection. Generate regenerates every out-of-date clip in one pass with + bounded concurrency instead of the whole document every time; a document with no clips yet + still uses the whole-document pipeline. Auto-split turns a `[Speaker:FX]`-tagged document + (optionally per paragraph) into clips and generates them in one action. +- **Transcript panel with character highlighting and a live gutter.** Each run is tinted by its + character, so speaker boundaries are visible without reading the inline `[Speaker:FX]:` + syntax, which converts into a real assignment the moment you finish a tagged line. Above the + editor sit two combos, Character and FX, that reflect the caret's clip and reassign the + selection (or the whole clip). The gutter labels once per character/FX change (`Narrator` / + `FX: Echo`) and shows a play button beside each out-of-date clip; click it to regenerate just + that clip. Out-of-date text is dash-underlined, thin rules show where clips end and where + Auto-split would cut. Copy/paste carries the character assignment along (with a setting for + whether a paste splits off its own run or inherits the destination's). +- **A multi-track timeline on a real seconds axis.** One lane per character; clips sit end to end + in text order at their real duration once generated and an estimated one (dashed outline, no + waveform) before, learned from `generation_stats.json`. A ruler with a playhead, a fixed + track-header column, Ctrl+wheel zoom. Dragging a clip pins it to a time or moves it to another + character's track; dragging it before an earlier clip also moves its text there. Shift+drag + inside a clip carves out a sub-range and replaces it with fresh TTS under any character. Each + clip has its own FX button for overrides to its character's preset. +- **Playback.** Play / pause / stop, click the ruler to seek, a playhead across all lanes, and + the transcript highlights and scrolls to the clip being played. Space toggles playback + anywhere but the text editor; Ctrl+Space toggles everywhere. Built on one + `sounddevice.OutputStream` that mixes the arrangement in the callback + (`kokoro_gui/audio/transport.py`), so the position is sample accurate. Loop toggle included. +- **Export.** File > Export mixes every clip down to one file (wav/mp3/flac/ogg) at its timeline + position, optionally with a `.srt` and per-clip files (`_001_Narrator.wav`). It warns + when clips are out of date and offers to generate first. The dialog also holds the project's + two bundle options: whether to bundle generated audio, and the audio format for new segments + (wav or flac). +- **`.tbaw` project bundles.** A project is one zip file that carries everything: the text and + clips, every generated segment, and every named voice mix, voice reference and FX preset it + uses, so it opens on another machine with the same engines installed. Save writes the whole + file in the background (the progress line shows it) and never leaves a half-written project + behind; Save As keeps the same working copy. Autosave writes only into the app's own working + copy (`cache/projects/`), so the file on disk is as new as your last Save. Closing with + unsaved changes asks Save / Discard / Cancel, and a crash offers to recover the unsaved + session next time the project opens, even after the file was renamed or moved. Launch reopens + the last project; New inherits the previous project's characters; Import Text asks whether to + add to the current project or start a new one. The window title names the project and shows + `*` while it has unsaved changes. A bundle only ever names audio inside itself: a + `document.json` pointing at some other file on the machine reads as a missing segment, and + Save never copies a file from outside the project's working copy into the bundle. (`.json` + projects from the 4.0 previews still open and are converted on the spot, a `.tbaw` written + next to the untouched `.json`, with matching audio carried over.) +- **Generation writes once.** A clip's audio lands straight in the project's working copy under + a name derived from what produced it, instead of one copy in `cache/` and another in the + output folder. Regenerating a clip that's already up to date (the gutter button) makes a fresh + take under a new name and leaves the old file for any other identical clip that plays it. + Opening a project made with another version of an engine keeps its clips clean and says so in + the status line; only clips you regenerate use the installed version. +- **Welcome screen.** Launch opens the last project, then puts a dialog over it: recent projects + (the open one first, Resume as the default button), New project, New from text file, Open + other, right-click to drop a row, Clear list, and a details pane with the file's path, + modified time, character and clip counts, audio length and engines. Untick "Show at startup" + for a silent resume; File > Welcome... brings it up any time. +- **Undo/redo.** Edit > Undo/Redo over a plain-Python undo stack. Typing undoes like a normal + text editor; character/FX assignments and timeline moves have their own history, and Ctrl+Z + reverts whichever happened most recently. +- **Settings and Audio FX follow the selection.** The old always-global Generation fields + (voice, speed, split pattern, plus volume/pitch/normalize/trim) live in a Settings dock that + reads and writes whatever's selected: the whole document's defaults, one clip's overrides, or + a character's preset. Editing a character affects every clip using it unless that clip has + its own override. Audio FX works the same way (project defaults, a character's preset with a + prompt before changing one that several clips share, or a clip override where slider drags + become one undoable step); the timeline's FX button selects the clip and raises the tab. +- **Audio FX are non-destructive.** Clips are generated as raw model output and the FX chain, + volume, pitch, normalize and trim are applied when the transport, the export or the timeline + waveform reads them. Move a slider, pick a preset, toggle "Apply": you hear it on the next + play, the clip stays generated, nothing is marked out of date. The Audio FX tab and playback + resolve a clip's stack through one function (`kokoro_gui/qt/fx_resolve.py`), and a clip with + its own FX override counts as FX-on even if its character's preset says off. +- **Characters replace bare presets.** Existing `presets/*.json` files migrate into `Character` + objects on first load (one per file, or a single "Default" character seeded from your last + settings if you had none), each with its own highlight color; Edit > Characters... edits + name, color, voice and FX preset. The preset files themselves are untouched, so this is a safe + downgrade path. Output folder, filename and format moved from the Settings tab to Export. +- **Dark and light themes** (Options > Theme, dark by default). One palette module feeds the + custom-painted widgets, the Qt palette and a stylesheet that styles every control: borderless + setting groups, rounded inputs and buttons, an underlined tab strip, thin scrollbars, a flat + progress line, painted play / pause / stop glyphs and a filled Generate button. The UI font + is Segoe UI / Inter / Noto Sans at 10pt, the transcript one point larger. Timeline clips have + rounded corners, a waveform in the clip's own darker shade and a label in black or white by + contrast; the default character palette is eight hues at one lightness and doubles as the + color picker's presets. +- **Pluggable engines.** `kokoro_gui/engines/` defines a backend interface (config schema, + voices, capabilities, project hooks) with three registered backends: Kokoro, Audio8 and a + sine-tone Dummy that exists to prove the abstraction isn't Kokoro-shaped. Options > Engine + swaps the Settings tab's fields and the Voices tab live. `kokoro_engine.py` is a slim core + backed by a `kokoro_gui/engine/` package split by feature area (text extraction, caching, + lexicon, presets, voice mixing, conversion, JIT, SRT). +- **Second TTS engine, Audio8 (voice cloning).** + [Audio8-TTS-Preview-0.6b](https://huggingface.co/Audio8/Audio8-TTS-Preview-0.6b), a zero-shot + voice-cloning model, is selectable from Options > Engine alongside Kokoro. Unlike Kokoro's + named voices, it clones a voice from a **reference WAV plus a transcript of what's said in it**; + a Voice Reference dock (shown only for engines that support this) lets you browse a WAV, + auto-transcribe it, edit the transcript, and save it under a name that then shows up in the + normal Voice dropdown. The TTS model pulls in `transformers`/`torchaudio` (new + `requirements.txt` entries) and loads with `trust_remote_code=True`. First use downloads it + from Hugging Face. +- **Two auto-transcription engines for Audio8's voice reference.** The Voice Reference dock's + "Auto-Transcribe" button has an engine picker (`kokoro_gui/engine/asr.py`, also runnable + standalone as `python -m kokoro_gui.engine.asr `). Default is + [Audio8-ASR-0.1B](https://huggingface.co/Audio8/Audio8-ASR-0.1B), online, higher quality, but + CC-BY-NC-4.0 (non-commercial), worth knowing if you build on this fork commercially. The + alternative is [Vosk](https://alphacephei.com/vosk), fully offline and Apache-2.0. Vosk needs a + model folder downloaded from https://alphacephei.com/vosk/models; the dock has a field for it + with Browse/Save/Reload buttons, but the value itself lives in `VOSK_MODEL_PATH` in a `.env` + file at the project root (copy `.env.example`) rather than in `config_qt.json` like every other + setting, since it's a one-time deployment detail rather than a per-session preference. Whatever + WAV format the reference audio is in, it's converted to the 16-bit mono PCM Vosk requires + before recognition runs, so you don't have to pre-convert it. +- **Segment cache rekeyed.** Cache entries are keyed on the voice's name and content rather than + its path, so the first generate after upgrading from 3.2.0 misses the old `cache/` entries. +- Not yet shipped: importing an existing audio recording and anchoring it to a transcript + (ASR-anchored import) is a planned follow-up, not part of this release. + +## New in 3.2.0 + +- **Cross-platform audio playback.** Preview and JIT playback now go through `sounddevice`/ + `soundfile` instead of the Windows-only `winsound` module, removing a hard Windows dependency + from `kokoro_engine.py`. ## New in 3.1.0 -- **JIT (Just-In-Time) Generation:** Real-time audio streaming. Start listening to your text immediately as it's being generated. -- **Audio FX Pipeline:** Integrated [Pedalboard](https://github.com/spotify/pedalboard) support for Reverb, Compression, and EQ. -- **Pronunciation Lexicon:** Create a custom dictionary to override how specific words or acronyms are pronounced. -- **Advanced Voice Mixing:** Create unique custom voices by mixing existing ones with precise control. -- **Scripted Multi-Speaker & FX:** Use a simple syntax `[Speaker:FX]: Text` to switch voices and audio effects on the fly. -- **Intelligent Caching:** Automatically caches generated segments to speed up repeated tasks. -- **Windows Quick Start:** New `run.bat` for easy one-click startup on Windows. +- **JIT (Just-In-Time) generation.** Real-time audio streaming. Start listening to your text + immediately as it's being generated. +- **Audio FX pipeline.** Integrated [Pedalboard](https://github.com/spotify/pedalboard) support + for Reverb, Compression, and EQ. +- **Pronunciation lexicon.** Create a custom dictionary to override how specific words or + acronyms are pronounced. +- **Advanced voice mixing.** Create unique custom voices by mixing existing ones with precise + control. +- **Scripted multi-speaker and FX.** Use a simple syntax `[Speaker:FX]: Text` to switch voices + and audio effects on the fly. +- **Intelligent caching.** Automatically caches generated segments to speed up repeated tasks. +- **Windows quick start.** New `run.bat` for easy one-click startup on Windows. ## Features -- **Multi-Source Input:** - - **Direct Text:** Paste text directly into the application. - - **File Support:** Load and process `.txt`, `.pdf`, and `.epub` files. Ideal for converting e-books to audiobooks. -- **High-Quality Voices & Languages:** - - Supports American English, British English, Spanish, French, Italian, Portuguese, Japanese, and Chinese. - - Wide variety of base voices plus custom voice mixing. -- **Generation Modes:** - - **Standard:** High-speed parallel processing for batch conversion. - - **JIT (Real-time):** Sequential generation with immediate playback and buffer management. -- **Audio FX & Post-Processing:** - - **Live FX:** Reverb, Compressor, Low/High Shelf filters. - - **Traditional:** Adjust Speed (0.5x to 2.0x), Volume, and Pitch. - - **Cleanup:** Normalize audio and Trim silence. -- **Smart Splitting:** Split text by newlines, paragraphs, or sentences for optimal prosody. -- **Flexible Output:** - - **Automatic Merging:** Combine all segments into a single high-quality `.wav`. - - **Subtitle Export:** Generate `.srt` files synchronized with the audio. - - **Custom Naming:** Define base filenames and output directories. -- **User Experience:** - - **Presets:** Save and load your favorite configurations (including FX). - - **Lexicon:** User-defined pronunciation overrides. - - **UI Customization:** Adjustable interface scaling and theme (Dark/Light/System). +- **Document-based editing:** + - The transcript is the source of truth. Generated audio is a render of the document's + current state, tracked per clip with cache-hash-based dirty detection. + - Multi-track timeline: one lane per character, drag clips to reassign or move them, carve + out and replace a sub-range with fresh TTS. + - Undo/redo for text edits and character reassignments. + - Auto-split a `[Speaker:FX]`-tagged script into clips and generate them in one action. + - Batch-generate only what's stale, or fall back to whole-document generation for projects + that don't use clips. +- **Multi-source input:** + - **Direct text:** type or paste directly into the transcript panel. + - **File support:** load `.txt`, `.pdf`, and `.epub` files. Good for turning e-books into + audiobooks. +- **Two synthesis engines, one interface:** + - **Kokoro** (default): named base voices plus custom mixing, 8 languages, 24,000 Hz, one + pipeline per worker thread for true parallel generation. + - **Audio8** (voice cloning): zero-shot cloning from a reference WAV + transcript, 44,100 Hz, + one shared lock-serialized model. + - Both register behind the same backend abstraction, so switching engines (Options > Engine) swaps + voices, sample rate, and the docks that make sense for that engine, live. +- **Generation modes:** + - **Standard:** parallel batch processing across a thread pool. + - **JIT (real-time):** streamed generation with immediate playback, for engines fast enough + to outrun playback. +- **Audio FX and post-processing** (non-destructive: applied on playback and export, never + written into a generated clip, so changing them never regenerates anything): + - **Live FX (Pedalboard):** Compressor, Limiter, Gain, shelf EQ, high/low-pass filters, Reverb, + Delay, Chorus, Distortion, Phaser, Clipping, Pitch Shift, Bitcrush, GSM Compressor. + - **Per-clip FX override**, layered on top of a character's own FX preset, on top of the + project's FX. + - **Traditional controls:** Speed (0.5x-2.0x), Volume, Pitch. + - **Cleanup:** Normalize and trim silence. +- **Smart splitting:** split text by newlines, paragraphs, or sentences for better prosody at the + seams. +- **Flexible output:** + - Combine all segments into one final `.wav` (or `.flac`/`.mp3`/`.ogg`), or keep the individual + segment files. + - **Subtitle export:** generate `.srt` files synced to the actual generated-segment durations. + - Custom output filenames and directories. +- **Presets and characters:** + - Characters wrap the existing `presets/*.json` shape: name, voice, settings, and a highlight + color, reusable across clips. + - Save and load FX presets separately from generation presets. + - Pronunciation lexicon: case-insensitive literal find-and-replace overrides, applied before + synthesis. +- **UI:** dark or light theme, persistent dock layouts (Workspace menu). ## Prerequisites @@ -55,11 +224,37 @@ https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e ## Installation -1. **Clone the repository:** +Two lines are available. Pick one before cloning. + +| | 4.0.0 beta (recommended) | 3.2.0 (old stable) | +|---|---|---| +| What it is | The DAW-style rebuild described above: PySide6, characters and clips on a timeline, `.tbaw` projects, Kokoro + Audio8 | The previous CustomTkinter app: one text box, one voice, generate to a folder | +| Status | Beta. Under active development; bugs are expected and reports are welcome | Frozen. No further fixes | +| Project files | `.tbaw`. The plan is for every 4.x release to open a `.tbaw` from any earlier 4.x, with the beta included (that's a goal, not a guarantee, until 4.0.0 final) | None. Output is loose `.wav` files, nothing to carry forward | +| Presets, mixes, lexicon | `presets/*.json` load as characters; `custom_voices/` mixes and the lexicon carry over | As-is | + +The two are separate codebases that share a name and the Kokoro model. There is no upgrade path +for a 3.2.0 install other than cloning 4.0.0 alongside it; there is nothing to migrate except the +`presets/` and `custom_voices/` folders, which you can copy across. + +1. **Clone the version you want:** + + 4.0.0 beta: ```bash - git clone https://github.com/CoffeeMethod/KokoroGUI.git + git clone --branch 4.0.0-beta.1 --depth 1 https://github.com/CoffeeMethod/KokoroGUI.git cd KokoroGUI ``` + `4.0.0-beta.1` is the tag of the current beta; the + [Releases](https://github.com/CoffeeMethod/KokoroGUI/releases) page lists every version and + has a source zip for each. Drop `--branch` to run the development branch instead. + + 3.2.0: + ```bash + git clone --branch 3.2.0 --depth 1 https://github.com/CoffeeMethod/KokoroGUI.git + cd KokoroGUI + ``` + Then follow the README in that checkout, not this one: its dependencies, prerequisites and + launch command differ. 2. **Create a virtual environment (recommended):** ```bash @@ -75,29 +270,65 @@ https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e pip install -r requirements.txt ``` - *Note: If you have issues with `torch`, visit [pytorch.org](https://pytorch.org/get-started/locally/) for specific installation instructions tailored to your OS and hardware.* + *Note: If you have issues with `torch`, visit [pytorch.org](https://pytorch.org/get-started/locally/) + for specific installation instructions tailored to your OS and hardware.* + +4. **(Optional) Configure offline ASR:** copy `.env.example` to `.env` and set `VOSK_MODEL_PATH` if + you plan to use the Vosk auto-transcription engine for the Voice Reference dock instead of the + default (online, non-commercial) Audio8 ASR model. ## Usage 1. **Run the application:** - - **Windows:** Double-click `run.bat` or run `python main.py` - - **Other:** Run `python main.py` + - **Windows:** double-click `run.bat` or run `python main.py` + - **Other:** run `python main.py` + + This launches the PySide6 (Qt) frontend. A welcome dialog lists recent projects with Resume, + New, New from text file and Open (untick "Show at startup" to skip it; File > Welcome... + reopens it); its details pane reads a project's clip count, audio length and engines straight + from the `.tbaw` manifest. Behind it, a menu bar (File / Edit / Options / Workspace) over a + 2x2 grid of docks: -2. **Configure your conversion:** - - Choose your input method (Direct Text or Load File). - - Select a voice and language from the dropdown menus. - - (Optional) Enable **JIT Generation** in Settings for real-time playback. - - (Optional) Use the **Lexicon** tab to add pronunciation overrides. - - (Optional) Use the **Custom Voice** tab to mix new voices. - - (Optional) Use the **FX** settings to add Reverb or Compression. + - **Transcript** (top-left): the editor, with Character and FX combos above it and a gutter + that names the speaker and offers a per-clip regenerate button. + - **Settings / Audio FX / Lexicon / Voices** (top-right, tabbed): voice, speed, language and + audio controls scoped to whatever's selected (document, clip or character); the Pedalboard + chain, also scoped; pronunciation overrides; and the engine's voice tools (Mixing for + Kokoro, Voice Reference for Audio8). + - **Timeline** (bottom-left): one lane per character on a seconds axis, ruler, playhead. + - **Transport** (bottom-right): play / pause / stop, Preview, Generate (with Auto-split in its + menu), Cancel, and the progress line. -3. **Preview & Convert:** - - Click "Preview Audio" to hear a short sample. - - Click "Start Generation" (or "Start Real-time JIT") to begin. + The engine, compute device and theme live under **Options**; **Workspace** switches between + the full grid and a Simple layout without the timeline. + +2. **Write and assign:** + - Type or paste text into the transcript, or File > Import Text for `.txt`/`.pdf`/`.epub`. + - Select a range and pick a character from the header combo (or the right-click Characters + menu), or write inline `[Speaker:FX]: Text` tags and let Generate > Auto-split turn them + into clips for you. + - Each character carries its own highlight color, visible right in the transcript. + +3. **Preview, generate, play, export:** + - Preview speaks a short sample of the current settings. + - Generate renders every out-of-date clip (or the whole document, for clip-free projects); + the gutter's play buttons regenerate one clip at a time. + - Play (or Space) plays the arrangement; the timeline playhead and the transcript follow. + - Drag a clip on the timeline to move it in time, onto another lane to reassign it, or + Shift+drag inside it to replace a sub-range with new TTS. + - File > Export mixes the timeline down to one file (plus optional `.srt` and per-clip files). + The same dialog holds the project's bundle options (bundle generated audio, wav or flac). + - File > Save writes the `.tbaw`; Ctrl+S is safe to hit any time, it runs in the background. + - Undo/redo any of the above from the Edit menu. ## Running Tests -The project has a `pytest` suite under `tests/` covering both `gui.py` and `kokoro_engine.py`. Playback no longer forces Windows-only (see [`playback.py`](playback.py)), and CI (`.github/workflows/tests.yml`) now runs the suite on both `windows-latest` and `ubuntu-latest` (the Linux leg installs `libportaudio2` for `sounddevice` and runs under `xvfb-run` since the GUI tests build real Tk windows). `macos-latest` isn't set up yet. +The project has a `pytest` suite under `tests/` covering the DAW document model (`tests/daw/`), the +Qt frontend (`tests/gui_qt/`), and `kokoro_engine.py`. Playback isn't Windows-only (see +[`playback.py`](playback.py)), and CI (`.github/workflows/tests.yml`) runs the engine, DAW and +audio suites on both `windows-latest` and `ubuntu-latest` (the Linux leg installs `libportaudio2` +for `sounddevice`). The Qt suite runs headless via `QT_QPA_PLATFORM=offscreen`, no virtual display +needed, but only locally; CI skips `tests/gui_qt/`. `macos-latest` isn't set up yet. 1. **Install test dependencies** (on top of `requirements.txt`): ```bash @@ -108,23 +339,42 @@ The project has a `pytest` suite under `tests/` covering both `gui.py` and `koko ```bash pytest ``` - This mocks the Kokoro pipeline, so it runs in seconds with no model download and no eSpeak NG required. Caching is disabled by default in every test except `tests/test_caching.py`. + This mocks the Kokoro pipeline, so it runs in seconds with no model download and no eSpeak NG + required. Caching is disabled by default in every test except `tests/test_caching.py`. 3. **Run the integration suite** (opt-in, real synthesis): ```bash pytest -m integration tests/integration -s ``` - Uses the real Kokoro pipeline, so it needs eSpeak NG on `PATH` (see Prerequisites) and downloads model weights on first use. It skips automatically if `espeak-ng` isn't found. Since real synthesis can't be verified automatically, each test speaks a short, self-describing sample naming the voice/mode and writes it to `tests/output//.../*_transcript.txt` next to the generated `.wav` — listen to the audio and compare against the transcript to confirm it sounds right. The `-s` flag also prints the same text to the terminal as each test runs. + Uses the real Kokoro pipeline, so it needs eSpeak NG on `PATH` (see Prerequisites) and downloads + model weights on first use. It skips automatically if `espeak-ng` isn't found. Since real + synthesis can't be verified automatically, each test speaks a short, self-describing sample + naming the voice/mode and writes it to `tests/output//.../*_transcript.txt` next to + the generated `.wav`, listen to the audio and compare against the transcript to confirm it + sounds right. The `-s` flag also prints the same text to the terminal as each test runs. ### CI -[.github/workflows/tests.yml](.github/workflows/tests.yml) runs step 2 above (`pytest`) on push/PR against `windows-latest` and `ubuntu-latest` (the Linux leg additionally installs `libportaudio2` and runs under `xvfb-run`, as noted above) after installing `requirements.txt` + `requirements-test.txt`. The fast suite needs no eSpeak NG or model download, so it's safe to run on every push/PR. The integration suite is slow and pulls model weights, so it's intentionally left out as a manual/opt-in run rather than part of the default pipeline. +[.github/workflows/tests.yml](.github/workflows/tests.yml) runs step 2 above on push/PR against +`windows-latest` and `ubuntu-latest` (the Linux leg additionally installs `libportaudio2`) after +installing `requirements.txt` + `requirements-test.txt`, as +`pytest --ignore=tests/gui_qt -p no:pytest-qt`: the engine, caching, DAW model, mixer and +transport tests, without the Qt widget suite. The fast suite needs no eSpeak NG or model download, +so it's safe to run on every push/PR. The integration suite is slow and pulls model weights, so +it's intentionally left out as a manual/opt-in run rather than part of the default pipeline. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the test conventions and what a PR needs. +Security reports go through the repository's Security tab, not Issues ([SECURITY.md](SECURITY.md)). ## Technologies Used -- **[Kokoro](https://github.com/hexgrad/kokoro):** The core TTS engine. +- **[Kokoro](https://github.com/hexgrad/kokoro):** The default TTS engine. +- **[Audio8-TTS-Preview-0.6b](https://huggingface.co/Audio8/Audio8-TTS-Preview-0.6b):** The + voice-cloning TTS engine. - **[Pedalboard](https://github.com/spotify/pedalboard):** Audio effects processing. -- **Customtkinter:** For the graphical user interface. +- **[PySide6](https://doc.qt.io/qtforpython/):** The graphical user interface. - **PyTorch:** Deep learning backend. -- **SoundFile:** For writing high-quality audio files. -- **PyPDF & EbookLib:** For parsing documents. +- **SoundFile / sounddevice:** Audio file I/O and cross-platform playback. +- **PyPDF & EbookLib:** Document parsing. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a9a3cdf --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security + +## Supported versions + +Only the latest 4.x release gets fixes. 3.x is unsupported. + +## Reporting + +Use the "Report a vulnerability" button on the repository's Security tab (GitHub private +vulnerability reporting). Don't open a public issue for anything you think is exploitable. You +should hear back within a week. + +## What counts + +KokoroGUI is a desktop app that reads files you give it. The parts worth a second look: + +- `.tbaw` project bundles are zip files from anywhere. `kokoro_gui/qt/project.py` refuses + absolute, drive-relative, `..` and symlink entries before extracting, and refuses a bundle + carrying a `.pt` voice file unless `torch >= 2.6`. A way past either of those is a bug we want + to hear about. +- Voice, preset, mix and reference names come from the GUI and end up in file paths. Every + resolver sanitizes with `os.path.basename()`. A name that escapes its directory is a bug. +- Text input: `.txt`, `.pdf` (`pypdf`) and `.epub` (`ebooklib` + BeautifulSoup) go through + `kokoro_gui/engine/text_extraction.py`. +- The Audio8 TTS and ASR models load from Hugging Face with `trust_remote_code=True`. That's a + property of those models, not something the app can turn off; the app never downloads anything + else at runtime. + +Out of scope: anything that needs the attacker to already run code as your user, and denial of +service by feeding the app an enormous document. diff --git a/config.json b/config.json deleted file mode 100644 index 13a8a80..0000000 --- a/config.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "appearance": "Dark", - "scaling": "100%", - "voice": "af_heart", - "filename": "output", - "out_dir": "audio_output", - "speed": 1.0, - "volume": 1.0, - "pitch": 0.0, - "num_threads": 1, - "split_pattern": "\\n+", - "separate": false, - "combine": true, - "export_subtitles": false, - "normalize": false, - "trim": false -} \ No newline at end of file diff --git a/docs/WEBSITE_ROADMAP.md b/docs/WEBSITE_ROADMAP.md new file mode 100644 index 0000000..c695464 --- /dev/null +++ b/docs/WEBSITE_ROADMAP.md @@ -0,0 +1,122 @@ +# Website roadmap + +Planning notes for [`docs/index.html`](index.html), the GitHub Pages landing page for the +project. This file is tracked in git, unlike the local-only `ROADMAP.md`/`PLAN_*.md` at the repo +root, because the site is a public-facing deliverable. It's fine for contributors to read where +it's headed. + +## Where it stands today + +Four pages, no build step: `docs/index.html` (the pitch: hero, three-step walkthrough, bento +feature grid, engine comparison table, signal chain, the `.tbaw` pitch, a changelog (4.0.0, then the README for older), +install), `docs/scripting.html` (the `[Preset:FXPreset]: Text` inline syntax, worked example +included), `docs/settings.html` (every field in every dock, including the two Audio8 fields that +are silently inert, the welcome dialog, and why JIT streaming only exists for engines that can +generate faster than real time) and `docs/format.html` (the `.tbaw` bundle: manifest keys, +`document.json` shape, segment key inputs, per-engine asset paths, the working copy, what Open +refuses). Shared tokens, nav, footer components and reference-page styles live in +`docs/assets/site.css`; `docs/assets/site.js` holds the theme toggle, the mobile menu and the +copy buttons. + +The 2026-09-13 pass restyled the site along current SaaS lines: Inter and JetBrains Mono instead +of Unbounded and IBM Plex, one violet accent instead of the teal/violet/coral gradient, dark as +the default palette with light as the override, an announcement pill over a centered hero, a +framed screenshot with a glow that swaps between `shell_dark.png` and `shell_light.png` with +the theme, and a four-column footer. Page-local ` + + + +
+ +
+ +
+
+
+ Format reference +

The .tbaw project bundle

+

Text based audio workflow. A .tbaw is a zip with a manifest, the document, the project's settings, its generated audio, and the voice and FX assets it names. This page is what you need to read or write one from a script, and what the app guarantees about it.

+ +
+
+ +
+
+
+
+

01 · LayoutOne zip, five kinds of entry

+

Three JSON files at the root, then directories. Audio is stored uncompressed (ZIP_STORED) so a 400 MB audiobook doesn't pay for deflate on data that won't shrink; everything else is deflated. The archive is written with Zip64 enabled, so a bundle over 4 GB is fine.

+

Entries the app doesn't own are preserved. A directory a newer version or a fourth engine adds is copied through byte for byte on the next Save, so a bundle survives a round trip through an older build.

+
+
+
my-audiobook.tbaw
+
manifest.json
+document.json
+project.json
+audio/generated/
+  <segment_key>_<index>.wav
+audio/imported/              reserved, empty today
+fx/
+  <preset name>.json
+engines/kokoro/voices/
+  <mix name>.pt
+engines/audio8/refs/
+  <reference name>.wav
+  <reference name>.txt
+
+
+
+
+ +
+
+

02 · manifest.jsonIdentity, versions, hashes

+

Read first on Open. The welcome dialog's details pane reads only this file, so stats exists to make that cheap.

+
+ + + + + + + + + + + + + + + + +
manifest.json
KeyMeaning
formatAlways "tbaw". Anything else is refused.
versionInteger, currently 1. A reader refuses a bundle whose version is higher than the one it implements.
requiresList of feature names the bundle depends on. Empty today. A reader refuses, by name, any feature it doesn't implement, which is how a future version can add something an old build can't fake.
project_idRandom id, stable across Save As. It names the working copy under cache/projects/ and is what crash recovery keys on, not the path.
created_bye.g. "KokoroGUI 4.0.0".
created / modifiedISO 8601 local timestamps, seconds precision. created is carried from the previous manifest.
includes{"generated_audio": bool, "imported_audio": bool}. Whether the audio directories were bundled. A bundle saved without generated audio regenerates its clips on open.
audio.format"wav" or "flac": the format new segments are written in.
stats{"clips", "characters", "duration_s"}. Counts and the sum of segment durations. Cosmetic; nothing reads audio to compute it.
engines{"<engine id>": {"version": str, "meta": {}}} for each engine the document's characters use. version is what the backend reported (Kokoro: the package version; Audio8: its model id). meta is per-backend and empty for both shipped engines.
assetsBundle path to SHA-256, for every entry under fx/ and engines/. Save skips re-hashing an asset whose hash is already in the working copy's index.
+
+

Unknown manifest keys are kept on Open and written back on Save.

+
+
+ +
+
+

03 · document.jsonThe document model

+

The same shape the app autosaves to its working copy, with audio paths made bundle-relative (audio/generated/…). Five top-level keys.

+
+ + + + + + + + + + + +
document.json
KeyContents
runsThe text, in order, as a list of {text, clip_id, kind}. The document's full text is the join of every run's text. clip_id is null for narration nobody has assigned; kind mirrors the clip's source.
clips{id, character_id, track_id, overrides, fx_override, timeline_timestamp, source, original_audio_path, segments}. A clip has no offsets of its own: its extent is wherever runs tags its id. overrides holds per-clip settings (voice, speed, take, fx_preset, apply_fx…); timeline_timestamp is null while the clip flows after its predecessor. source is "generated" or "imported".
clips[].segments{id, order_index, text, cache_key, audio_path, duration, raw, engine_version}. One per engine-level chunk. cache_key is the segment key, and audio_path is audio/generated/<cache_key>_<order_index>.<ext>. raw is true for every segment 4.0.0 generates (FX are applied at read time); a segment without the field, from a 4.0 preview build, is treated as baked and regenerated once. engine_version is the version that produced it.
tracks{id, name, character_id, order_index}. A lane. Kept separate from characters so a clip can sit on another character's track.
characters{id, name, preset_data, highlight_color, backend_id}. preset_data is the presets/*.json shape: voice, speed, split pattern, fx_preset, apply_fx, and so on. backend_id is the engine the voice belongs to.
settingsThe document's own defaults, the "nothing selected" scope of the Settings tab.
+
+
+ +
+

Unknown fields ride along. Every one of these objects has an extra dict on the app side. A field this version doesn't know is parked there on load and written back on save, so a tool can add its own keys to a clip or character and they survive a round trip through the app.

+
+
+
+
+ +
+
+

04 · project.jsonPer-project settings

+

The project_settings block. Small, and the only place the two bundle options live.

+
+ + + + + + + + +
project.json
KeyContents
exportThe Export dialog's remembered values: output folder, base filename, format, whether to write an .srt and per-clip files.
bundle{"include_generated_audio": true, "include_imported_audio": true, "audio_format": "wav"} by default. What Save puts in the zip and how new segments are encoded. Editable from the Export dialog.
workspaceOptional. A workspace name that overrides the app-level one when this project opens.
+
+
+
+ +
+
+

05 · Generated audioOne file per segment, named by what produced it

+
+

Every generated segment is audio/generated/<segment_key>_<order_index>.<wav|flac>, raw model output with no FX, volume, pitch, normalize or trim applied. The file is written once and never overwritten: a regenerate of a clean clip bumps the clip's take, which changes the key, which is a new file. The old file stays for any other clip that plays it until the project closes, when files no segment references are deleted.

+

Two clips with the same text, voice, speed, language, engine version and take share a key, so they share a file. A bundle with includes.generated_audio false has no audio/generated/ entries; its segments keep their cache_key, and the app regenerates them on the first Generate (a cache hit if the machine's own cache has the key).

+
+
+
+ +
+
+
+
+

06 · Segment keysThe one hash

+

A segment key is a SHA-256 over a pipe-joined k=v list. The same function names the file, stamps Segment.cache_key, and drives the out-of-date check in the transcript gutter, so the three can't disagree.

+

The voice enters as a name plus a content fingerprint, never a path, which is why a key is the same on every machine. Nothing after generation is in it: split pattern, FX, output format, normalize and trim all apply to the same raw segment, so they don't change the key and don't dirty a clip.

+

Kokoro adds nothing to extra. Audio8 adds its reference transcript and sampling settings, since two clones of the same WAV with different transcripts are different voices.

+
+
+
segment key inputs · schema 3
+
schema_version=3
+engine_id=kokoro
+engine_version=0.7.11      # kokoro package version
+text=The old house stood…
+voice=narrator_mix        # basename, never a path
+voice_fingerprint=9c1e…   # file hash; the name if built-in
+speed=0.943874            # speed ÷ 2^(pitch/12)
+lang_code=a
+extra_take=2               # only when non-zero
+extra_ref_transcript=…    # Audio8 only, + sampling
+
+sha256("|".join(f"{k}={v}"))
+
+
+
+
+ +
+
+

07 · Engine assetsWhat each engine puts under engines/

+

Save walks the document, collects the voice names its characters and clips use, and asks each backend for the files those names need. Only named assets ship; a built-in Kokoro voice is a name with no file.

+
+ + + + + + + + +
Bundle paths by engine
PathWhat it is
fx/<name>.jsonAn FX preset a character's fx_preset or a clip's override names. The Audio FX tab's "Save FX Preset" shape.
engines/kokoro/voices/<name>.ptA custom mix made in the Mixing tab: a saved voice tensor. Loaded with torch.load(weights_only=True), see safety.
engines/audio8/refs/<name>.wav + .txtA cloning reference and its word-for-word transcript. The encoded reference codes are derived data and stay in the machine's own cache; the app warms them on the first generate after open.
+
+
+

On Open, the bundle's own assets take priority. The Voices tab lists a project's mixes and references before the global ones, and the same name in both resolves to the bundle's copy. Nothing writes back into engines/ except Open's extraction: saving a new mix or reference goes to the global store, and the next Save copies it in.

+
+
+
+ +
+
+

08 · CompatibilityVersions, engines, and the .json past

+
+

Engine versions. A segment is compared under the engine_version it stores while its file is present. So a project made with one Kokoro release opens clean on a machine with another, and the status line says which version the clips came from. Only a clip you regenerate uses the installed version.

+

Preview-era projects. A .json project from the 4.0 previews (the document.json shape plus a top-level project_settings) still opens. The app builds a working copy, adopts any segment whose stored key matches the schema-2 formula and whose file exists (copying it in under its schema-3 key), and saves a .tbaw next to the original. The .json is left alone.

+

Forward compatibility. version and requires are the two gates. A reader refuses a higher version outright, and refuses a bundle that requires a feature it doesn't know, by name. Everything else, unknown entries, unknown manifest keys, unknown object fields, is carried through.

+
+
+
+ +
+
+

09 · The working copyWhere the app actually edits

+
+

The app never edits a .tbaw in place. Open extracts it to cache/projects/<project_id>/, the working copy, and holds an OS lock on <dir>/lock for as long as the project is open. Autosave writes document.json and project.json there; clip generation writes into its audio/generated/. Save rebuilds the zip from the directory, into <path>.tmp, then swaps it over the old file, so a crash mid-save can't leave a truncated project.

+

A session.json next to the lock records which file the directory belongs to, the zip's size and mtime at extraction, a digest of the last saved document, and whether the directory is ahead of the file. That digest, not an mtime, is what the window title's * means. Opening a project whose directory is dirty offers to recover the unsaved session; Resume from the welcome dialog reuses a matching directory without re-extracting, which is why it's fast.

+

A clean close deletes audio no segment references and evicts other clean, unlocked working copies, keeping the last project's. Neither session.json nor lock is ever written into the bundle.

+
+
+
+ +
+
+

10 · Safety on openA bundle is untrusted input

+
+

Before extracting, every entry name is checked: an absolute path on either OS, a drive-relative path, a .. component or a symlink is refused and nothing is written. Free space is checked against the uncompressed size from the central directory first.

+

A bundle carrying a .pt voice mix is refused unless the machine's torch is 2.6 or newer, where torch.load defaults to weights_only=True and can't execute a pickle payload. Upgrade torch to open it; there's no override.

+
+
+
+
+ + + + + + diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..f99902c --- /dev/null +++ b/docs/index.html @@ -0,0 +1,566 @@ + + + + + +Kokoro GUI · Text to speech, edited like a DAW project + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+
+
+ NEW 4.0.0: the DAW-style rebuild, projects in one .tbaw file +

Text to speech, edited like a DAW project.

+

Kokoro GUI is a desktop workstation for turning a script, or a whole ebook, into audio. Assign characters to a transcript, watch clips land on their own timeline track, and regenerate only what changed. Everything runs on your machine.

+ +
+ Runs locally, no API keys + Apache-2.0 + Windows · macOS · Linux + 2 synthesis engines +
+
+ +
+
+ + Kokoro GUI 4.0 · Advanced workspace +
+ Kokoro GUI: a transcript with a character gutter and settings tabs above a seconds-axis timeline with waveforms, a playhead, and the transport + +
+
+
+
+ +
+
+ Built on + + + + + + +
+
+ +
+
+
+ How it works +

Write, assign, generate, export

+

The transcript is the source of truth. Audio is a render of it, tracked per clip, so you never wonder which file is current.

+
+
+
+ 01 +

Write the script

+

Paste text or import a .txt, .pdf or .epub. Tag speakers inline, or select a range and pick a character from the combo above the editor.

+
[Narrator]: The old house stood at the end of the lane. +[Villain:Whisper]: Someone should not have come here.
+
+
+ 02 +

Generate what changed

+

Auto-split turns tags into clips on each character's track. Generate renders only the clips whose text or settings moved since their last take. Edit one line, regenerate one clip.

+
Narrator ▶ 2 clips out of date +Villain clean, cache hit +Generate → 2 segments · 0:07
+
+
+ 03 +

Mix and export

+

Play the arrangement from the transport, drag clips in time, tweak FX while it plays. Export mixes every track down to one file, with an .srt and per-clip files if you want them.

+
audiobook.wav 12:41 +audiobook.srt 38 cues +audiobook_001_Narrator.wav …
+
+
+
+
+ +
+
+
+ Features +

A 2x2 grid of docks, and the model behind it

+

Transcript and settings tabs on top, timeline and transport underneath. Every panel is a dock; Workspace > Simple hides the timeline when you just want to read text aloud.

+
+
+ +
+
+

A transcript that knows who's speaking

+

Each run of text is colored by its character. The gutter names the speaker and FX once per change and draws a play button beside every clip that's out of date. Click it to regenerate that clip alone.

+ + Transcript reference → +
+ +
+
+

A timeline on a real seconds axis

+

One track per character. Generated clips show waveforms; ungenerated ones are dashed estimates that learn from past runs. Drag a clip in time, onto another track, or Shift+drag inside it to replace a sub-range with new TTS.

+ + Timeline reference → +
+ +
+
+

FX that never dirty a clip

+

Seventeen Pedalboard sliders in five groups, applied when a clip is played or exported, never written into it. Move a slider mid-play and you hear it on the next pass.

+ + FX reference → +
+ +
+
+

Regenerate only what moved

+

Every segment is keyed by a hash of its text, voice, speed and engine version. Change any of those and the clip goes out of date. Change anything else and it doesn't.

+ How segment keys work → +
+ +
+
+

Settings scoped to the selection

+

Nothing selected edits the project defaults. A clip selected edits its overrides. A character selected edits its preset, and every clip using it follows.

+ Settings reference → +
+ +
+
+

Two ways to get a voice

+

With Kokoro, pick a named voice or blend two into a new one in the Mixing tab. With Audio8, drop in a reference WAV, let the bundled ASR write the transcript, and save it as a clone. The Voices tab shows whichever tool the active engine supports.

+ Compare the engines → +
+ +
+
+

Projects travel as one file

+

A .tbaw (text based audio workflow) bundle holds the text, the clips, every generated segment, and the voice mixes, references and FX presets the project names. Open it on another machine with the same engines and it plays.

+ What's inside a .tbaw → +
+ +
+
+
+ +
+
+
+ Engines +

Two local models, one interface

+

Both sit behind the same backend abstraction. Options > Engine swaps the voice list, the sample rate and the Voices tab live. Nothing else about the app changes.

+
+
+ + + + + + + + + + + + + + + + + +
Kokoro default · hexgrad/kokoroAudio8 Audio8-TTS-Preview-0.6b
VoicesNamed base voices, plus custom mixesZero-shot cloning from a reference WAV and its transcript
LanguagesAmerican and British English, Spanish, French, Italian, Portuguese, Japanese, ChineseWhatever the reference clip speaks
Sample rate24,000 Hz44,100 Hz
ParallelismOne pipeline per worker thread, so chunks render in parallelOne shared model behind a lock; threads don't help
JIT streaming● Supported○ Falls back to batch
Voices tabMixingVoice Reference, with Auto-transcribe
Licensing noteApache-2.0 modelThe default ASR model for Auto-transcribe (Audio8-ASR-0.1B) is CC-BY-NC-4.0; Vosk is the Apache-2.0, offline alternative
+
+
+
+ +
+
+
+ Post-processing +

One fixed chain, applied at read time

+

This is the order in process_audio. It runs when a clip is played, drawn on the timeline, or exported, over the raw model output on disk.

+
+
+
01

Trim

Leading and trailing silence, if enabled

+
+
02

Volume

A plain gain multiply

+
+
03

Pitch

Shifted by resampling

+
+
04

FX chain

Every enabled Pedalboard effect

+
+
05

Normalize

Peak to 98% of full scale, last

+
+
+
+ +
+
+
+
+
+ Projects +

One .tbaw per project. Nothing else to keep track of.

+

Text based audio workflow. A zip with a manifest, so anyone can open it, and everything a project needs to play back on a second machine.

+
+
    +
  • Saves in the background. Ctrl+S writes a temp file and swaps it in, so a crash never leaves a half-written project. Autosave goes to a working copy, never to your file.
  • +
  • Carries its voices. Custom Kokoro mixes, Audio8 references and FX presets the project names ride inside it. The Voices tab lists a bundle's own voices first.
  • +
  • Recovers after a crash. Reopen the project and it offers the unsaved session, even if the file was renamed or moved in between.
  • +
  • Small when you want it. Untick "bundle generated audio" in the Export dialog and the file holds just text and settings; clips regenerate on open.
  • +
+ +
+
+
my-audiobook.tbaw
+
manifest.json      format, version, stats, hashes
+document.json      text, clips, tracks, characters
+project.json       export and bundle options
+audio/generated/
+  3f9a…c2_0.wav    one file per segment
+fx/
+  Whisper.json     named FX presets
+engines/kokoro/voices/
+  narrator_mix.pt  custom voice mixes
+engines/audio8/refs/
+  villain.wav      cloning reference
+  villain.txt      and its transcript
+
+
+
+
+ +
+
+
+ Changelog +

What's new

+

4.0.0 is the DAW-style rebuild. The full history, back to 3.1, is in the README.

+
+
+
+
4.0.0 LATEST
+
+

A document, not a text box

+

The rebuild. 3.2.0 was a CustomTkinter form; 4.0.0 is a PySide6 shell over a document with clips, tracks and characters layered on top, per-clip dirty tracking, undo and redo, and a settings panel scoped to the selection. Transcript | Settings, Audio FX, Lexicon and Voices tabs on top; Timeline | Transport underneath, with saved Advanced and Simple workspaces. Light and dark themes.

+

Clips sit on a real seconds axis with waveforms, a ruler and a playhead. Play, pause, stop and seek through one sounddevice stream that mixes the arrangement in the callback. File > Export mixes the timeline down to one file with optional .srt and per-clip files. Audio FX are non-destructive: clips are raw model output and the FX chain, volume, pitch, normalize and trim are applied when the transport, the export or the timeline reads them, so moving a slider never marks a clip out of date.

+

A project is one .tbaw file that carries the text, the clips, every generated segment and every named voice mix, reference and FX preset it uses. Save runs in the background and never leaves a half-written file; a crash offers to recover the unsaved session. A clip's audio lands in the project's working copy under a name derived from what produced it, and regenerating a clean clip makes a fresh take. Launch reopens the last project under a welcome dialog with recents, New, New from text file and a details pane.

+

Engines are pluggable: Audio8 voice cloning joined Kokoro as a second real backend, with two ASR choices for auto-transcribing a reference.

+
+
+
+
+ +
+
+
+ Install +

From clone to first sample

+

Python 3.11 or newer and eSpeak NG. Both models download from Hugging Face the first time you pick them.

+
+
+ +
+

4.0.0 is a beta, and a different program from 3.2.0. 3.2.0 is the old CustomTkinter app: one text box, one voice, files to a folder. It's frozen and gets no fixes. 4.0.0 is the rebuild this site describes. The intent is that every 4.x release opens a .tbaw made by any earlier 4.x, the beta included; treat that as a goal rather than a promise until 4.0.0 final.

+

Still want 3.2.0? git clone --branch 3.2.0 --depth 1 the same URL and follow the README in that checkout; its dependencies and launch steps differ from the ones here. Nothing carries over between the two except the presets/ and custom_voices/ folders.

+
+
+
+
+
+
Terminal
+
# clone the current beta (the tag name changes each release;
+# the Releases page lists them all)
+git clone --branch 4.0.0-beta.1 --depth 1 https://github.com/CoffeeMethod/KokoroGUI.git
+cd KokoroGUI
+
+# virtual environment
+python -m venv .venv
+.venv\Scripts\activate       # Windows
+source .venv/bin/activate    # macOS / Linux
+
+# install and run
+pip install -r requirements.txt
+python main.py               # or run.bat on Windows
+
+
+
eSpeak NG, the one system dependency
+
# Windows: run the installer from
+https://github.com/espeak-ng/espeak-ng/releases
+
+# macOS
+brew install espeak-ng
+
+# Debian / Ubuntu
+sudo apt install espeak-ng
+
+# Fedora
+sudo dnf install espeak-ng
+
+
+
+

Python 3.11+

The only hard language-version requirement.

+

eSpeak NG

Kokoro's phonemization step needs it at runtime. Install it before the first run.

+

A GPU is optional

Both models run on CPU. Options > Device picks CUDA when PyTorch reports one.

+

First run downloads weights

Kokoro and Audio8 pull from Hugging Face on first use. Audio8 loads with trust_remote_code=True.

+

Offline transcription

Copy .env.example to .env and set VOSK_MODEL_PATH to use Vosk instead of the default Audio8 ASR model.

+

Trouble with torch?

Follow the platform-specific install at pytorch.org before pip install -r requirements.txt.

+
+
+
+
+ +
+
+
+ Open source +

Local, inspectable, Apache-2.0

+

No account, no upload, no per-character billing. Clone it, read the code, ship your audiobook.

+ +
+
+
+
+ + + + + + diff --git a/docs/scripting.html b/docs/scripting.html new file mode 100644 index 0000000..e9a9361 --- /dev/null +++ b/docs/scripting.html @@ -0,0 +1,237 @@ + + + + + +Kokoro GUI · Inline scripting guide + + + + + + + + + +
+ +
+ +
+
+
+ Guide +

Multiple speakers and effects, in the same block of text

+

The transcript understands one piece of syntax on top of plain text: a bracketed tag at the start of a line switches which character, and optionally which saved FX preset, applies to everything after it. This page covers exactly what that tag looks up, how the text around it gets split, and what happens when a tag doesn't match anything you've saved. It's the fast-typing alternative to the transcript's Characters menu, not a separate feature from it, both end up assigning the same character metadata to the same text.

+ +
+
+ +
+
+
+

01 · SyntaxTwo forms of one tag

+

Both forms sit at the start of a line, followed by a colon and the text that tag applies to:

+
+
+
Syntax
+
[PresetName]: Text spoken with that preset's voice and settings.
+
+[PresetName:FXPresetName]: Same, plus that FX preset layered on top.
+
+
+

Everything up to the next tag (or the end of the text) belongs to that segment. Plain text with no tag at all is treated as a single segment using whatever voice and settings the Settings tab is currently set to, exactly like today.

+
+
+
+ +
+
+
+

02 · What gets looked upPresetName is a character name, not a raw voice

+

This is the part that trips people up: the name in brackets isn't a voice ID like af_heart. It's the name of a character (Edit > Characters..., or one of the legacy presets/<name>.json snapshots, which each become a character with that same name on load), so a tag's name is looked up as a character, case-insensitively, against whatever's saved. If you haven't saved anything under that name, the tag has nothing to find.

+

Same idea for the FX half: FXPresetName is the name of an FX preset saved from the FX dock's "Save FX Preset" button, stored as presets/fx/<name>.json. The FX dock's full field list is on the settings reference.

+

So before a script like [Narrator]: ... means anything, a preset literally named Narrator has to already exist in presets/.

+
+
+ +
+

The FX-only trick. Leave the preset name blank and start with a colon: [:FXPresetName]: Text. Since there's nothing before the colon, no preset gets applied, only the FX preset does, and the voice stays whatever the Settings tab is currently set to. Handy for layering an effect onto a line without also switching who's speaking.

+
+
+
+
+ +
+
+
+

03 · How segments splitEach tag resets to your base settings, not the last tag's

+

This is the second thing worth knowing before writing a long script: segments don't inherit from each other. Each one starts fresh from the Settings tab's project-scope settings, then layers on its own tag if it has one.

+
+
    +
  1. Untagged text before the first tag, or text with no tags at all, uses the Settings tab's project-scope settings as-is.

  2. +
  3. A [Preset]: tag loads that preset and overlays it on top of the base settings for everything until the next tag.

  4. +
  5. A [:FXPreset]: tag keeps the base voice and settings, and only overlays the FX preset.

  6. +
  7. An empty segment, one tag immediately followed by another with no text between them, is dropped rather than generating a silent clip.

  8. +
  9. Each segment is independently split into smaller chunks afterward using your Split By setting, so one [Preset]: block can still turn into several audio chunks under the hood. The tags layer on top of smart splitting, they don't replace it.

  10. +
+
+
+ +
+
+
+

04 · Worked exampleA four-line script, segment by segment

+

Assume two presets already exist, Narrator and VillainVoice, along with two FX presets, Reverb Heavy and Whisper. The Settings tab's currently selected voice is whatever you last picked, called out below as "the base voice."

+
+
+
Script
+
[Narrator]: The old house stood at the end of the lane.
+
+[Narrator:Reverb Heavy]: A single door creaked open in the dark hallway.
+
+[VillainVoice]: "Who dares enter my domain?" a voice boomed.
+
+[:Whisper]: ...someone should not have come here.
+
+
+
+ [Narrator]: +

Loads the Narrator preset. No FX preset, so whatever FX the preset itself specifies (usually none) is all that applies.

+
+
+ [Narrator:Reverb Heavy]: +

Loads Narrator again, since presets don't carry over between segments, and layers the Reverb Heavy FX preset on top for this line only.

+
+
+ [VillainVoice]: +

Switches to a completely different preset. No FX preset here, so no FX preset gets applied, not "whatever the last one was."

+
+
+ [:Whisper]: +

Blank preset name, so this line uses the base voice (not VillainVoice, that only applied to the segment above), with the Whisper FX preset layered on.

+
+
+
+
+ +
+
+
+

05 · When a tag doesn't matchTypos don't stop the run

+

If a preset name in brackets doesn't match a saved file, that segment doesn't fail. It falls back to the Settings tab's project-scope settings, generation continues, and the status line shows a warning: Warning: Preset 'X' not found.

+

Same story for a missing FX preset: Warning: FX Preset 'X' not found. That segment just skips the FX step rather than erroring out.

+

The practical upshot: a typo in a long script quietly generates that block in the wrong voice instead of stopping you. Worth watching the status line while a multi-speaker script is running, especially the first time you use a new preset name.

+
+
+
+ +
+
+
+

06 · Turning tags into clipsAuto-split does the Characters-menu work for you

+

Writing a whole script with these tags and then manually selecting each block to assign it in the timeline works, but it's repetitive. Auto-split reads the same tags this page describes and creates one clip per tagged span automatically, landing each clip on its matching character's track, then generates them in one action.

+

Ask for the finer split and it also cuts each tagged span on paragraph breaks, so one [Narrator]: block spanning three paragraphs becomes three clips instead of one. Untagged narration only gets auto-clipped when your project has exactly one character, otherwise it's left alone, same as text nobody's assigned a character to today.

+

A span whose tag name doesn't match any saved character contributes no clip and shows up as a warning rather than failing the whole run, the same forgiving behavior described below for a missing preset.

+
+
+
+ +
+
+
+

07 · Plays well withLexicon, generation modes, and both engines

+

The Lexicon dock's pronunciation overrides apply to every segment's text before synthesis, regardless of which preset or FX preset is active. Lexicon rules are global, not per-speaker.

+

This syntax works the same way in Standard and JIT generation, both call the same parser before splitting text into chunks.

+

Both Kokoro and Audio8 support it today. Since the tags reference saved presets, not built-in engine voices, switching the active engine doesn't break an existing script as long as the preset names still resolve to something valid for the new engine.

+
+
+
+
+ + + + + + diff --git a/docs/settings.html b/docs/settings.html new file mode 100644 index 0000000..b7a3f74 --- /dev/null +++ b/docs/settings.html @@ -0,0 +1,465 @@ + + + + + +Kokoro GUI · Settings reference + + + + + + + + + +
+ +
+ +
+
+
+ Reference +

What every setting actually does

+

Every field in every dock, grouped the way they're grouped in the app, with the parts that aren't obvious from the label alone called out. Three things are worth reading before the rest: the Settings dock's fields mean something different depending on what's selected, Audio8 quietly ignores two of its own generation fields, and JIT streaming isn't a checkbox every engine gets.

+ +
+
+ +
+
+
+

Options menuEngine, device, theme

+

Options > Engine is the one setting that changes what every other setting on this page looks like: switching it rebuilds the Settings tab's fields from the new engine's schema and swaps what sits behind the Voices tab (Mixing for Kokoro, Voice Reference for Audio8, nothing for the Dummy engine). Everything below is written per-engine where the two differ.

+

Options > Device picks CPU / CUDA / Auto for the Kokoro pipeline (the CUDA entry is greyed out when torch reports no device) and re-initializes the engine. Options > Theme switches between the dark (default) and light palettes. The three checkable entries under them are the old Settings dialog: whether copying text carries its character/FX along, whether a paste splits off its own run, and JIT streaming for clip-free documents. The welcome dialog's "Show at startup" box is the one launch setting that isn't here; it lives on the dialog itself (File > Welcome…).

+
+
+
+ +
+
+
+

Transcript dockThe transcript

+

The Transcript dock is a syntax-highlighting editor over the document's canonical text, not a plain input field. Two combos sit above it: Character and FX, both reflecting the clip under the caret. Changing Character reassigns the selection (or the caret's whole clip, or its line for untagged text); changing FX sets a named FX override on the caret's clip, undoable like everything else. The left gutter names the speaker once per character/FX change (Narrator on one line, FX: Echo under it) and shows a small play button beside each out-of-date clip that regenerates just that clip. Out-of-date text is dash-underlined; thin rules mark clip boundaries and where Auto-split would cut.

+

Every run of text is colored by its assigned character and FX, so the boundaries between speakers and effects are visible at a glance, without reading the inline [Speaker:FX]: syntax.

+

Selecting a range and opening the Characters menu reassigns that range's character/FX directly. Placing the caret inside an existing run instead shows that run's currently active voice/FX in the Settings dock. Copying a range carries its character/FX metadata with it; pasting into a run belonging to a different character splits the paste into its own run rather than silently overwriting the destination's assignment, unless "character/FX paste splits" is turned off, in which case a paste inherits whatever it lands on.

+
+
+
+ +
+
+
+

Settings tabOne panel, three scopes

+

The schema-driven form, scoped to whatever self.app.selection currently points at:

+
+
+ + + + + + + + +
Settings dock scopes
SelectionValues come fromEdits write to
Nothing selectedThe app's whole-document defaultsApp settings (config_qt.json)
A clipIts character's preset, merged with its own overridesclip.overrides, never the app defaults
A characterThat character's saved presetThe character's preset directly, so every non-overridden clip using it updates live
+
+

Only the fields below can vary per clip or character. Everything else (Language, Parallel threads, Enable segment cache, and any engine's own non-preset fields like Audio8's sampling knobs) is rendered disabled, not hidden, while a clip or character is selected, since those settings only make sense at the whole-document level.

+

Schema-driven: the fields below are what both real engines currently declare. Rows marked Audio8 only or Kokoro only don't exist on the other engine's dock at all.

+
+ + + + + + + + + + + + + + + + +
Voice & generation fields
SettingWhat it does
LanguageFor Kokoro, picks which phonemization pipeline runs; each language uses its own KPipeline and voice list. For Audio8 it's one of eleven labels from the model card. See the callout below: it currently isn't sent to the model at all.
VoiceKokoro: a named base voice (af_heart, bm_daniel, ...) or a saved custom mix from the Mixing dock. Audio8: a saved Voice Reference (WAV + transcript pair) from the Voice Reference dock.
SpeedKokoro: 0.5x-2.0x, passed straight to the pipeline as a native generation parameter. Audio8: shown in the same range, but see the callout below, it's currently not forwarded to the model either.
Split byNatural (newlines), Paragraphs (double newline), or Sentences (splits on ./!/?, with a regex guard against splitting mid-abbreviation). Controls where a long input gets cut into chunks before generation.
Output formatwav, flac, mp3, or ogg. Same four choices on both engines.
Parallel threadsKokoro: 1-32, each worker thread gets its own KPipeline, so raising this genuinely parallelizes generation. Audio8: capped at 1-4 in the schema, and since every segment is serialized through one shared, locked model, raising it doesn't parallelize the model calls themselves.
Enable segment cacheCaches each generated segment as a .wav under cache/, keyed by a hash of the settings that affect what gets generated. Re-running with unchanged settings skips regenerating that segment.
Cache reference encoding Audio8 onlyA second, separate cache that stores the model's encoded reference audio, keyed by the reference file's contents. Repeated segments against the same voice reference skip re-running the audio encoder, independent of the segment cache above.
Max new tokens Audio8 onlyCaps how many tokens the model can generate per segment. Default 1024, hard-capped at 2048 to match the model's real max_seq_len; anything higher gets silently clamped by the model anyway.
Temperature Audio8 onlySampling temperature, default 0.8. Higher values add more variation between takes of the same text.
Top P / Top K Audio8 onlyNucleus and top-k sampling cutoffs, default 0.95 and 50. Standard token-sampling controls for the model's decoder.
+
+
+ +
+

Language and Speed do nothing on Audio8, currently. The model's own processor doesn't accept a language or speed argument at all; passing either raises a hard error. So audio8_tts.py keeps both in its internal function signature only to match the shape shared with the other engines, and never forwards them to the model. Audio8 infers the spoken language from the reference clip and text, and always generates at its own pace. The two fields still show up in the dock because the schema doesn't currently distinguish "displayed" from "functional." Worth knowing before you spend time chasing a Speed slider that isn't moving anything.

+
+
+
+
+ +
+
+

Settings tabAudio control

+

Hand-coded, not schema-driven, so every engine gets the same fields here, still subject to the same three-scope rules above. These are the post-processing steps from the signal chain on the home page.

+
+ + + + + + + + + + +
Audio control
SettingWhat it does
Volume0.1x-2.0x. A plain multiply on the waveform, applied second in the chain, after Trim and before Pitch.
Pitch (st)-12 to +12 semitones, shifted by resampling to a new length. This changes duration along with pitch, the classic "chipmunk" effect: raise the pitch and playback gets faster, lower it and playback gets slower. For pitch without a duration change, use the FX dock's separate Pitch Shift effect instead.
FX preset / ApplyLoads a saved FX preset into the FX dock's sliders. The Apply checkbox is the master switch for the whole FX chain below; unchecked, none of the FX dock's effects run even if individually enabled.
NormalizeScales the segment so its loudest peak sits at 98% of full scale. A simple peak normalize, not a loudness (LUFS) normalize. Runs last in the chain, after FX.
Trim silenceStrips leading and trailing audio below a fixed amplitude threshold. Runs first in the chain, before Volume.
+
+
+
+ +
+
+

Timeline dockTimeline

+

One track per character on a real seconds axis, bottom-left of the window. Clips sit end to end in text order across all tracks: at their real duration once generated (with a waveform), at an estimated one before (dashed outline, no waveform; the estimate learns from generation_stats.json). A ruler runs across the top, a fixed header column names the tracks, and Ctrl+wheel zooms between 20 and 400 pixels per second.

+
+ + + + + + + + + + + + + +
Timeline
ActionWhat it does
Right-click a clip > GenerateRenders just that clip's text/settings through the normal cache-aware generation path, populating its segments with real audio.
Right-click a clip > PlayPlays that clip's cached audio.
Per-clip FX buttonOpens an FX override for that one clip, applied on top of the FX chain and winning over its character's own FX preset. Button opacity shows state at a glance: 50% means no FX override, 90% means one's active.
Drag a clip left or rightPins it to that start time (snapping to other clips' edges and the playhead). Drop it at or before the start of the clip that precedes it in the text and its text moves there too, so the timeline and the transcript stay in the same order. Right-click > Unpin from timeline puts it back in the flow.
Drag a clip onto another trackReassigns the clip to that track's character (with a confirmation prompt when the characters actually differ) or just moves it to that lane.
Shift+drag inside a clipCarves out the selected sub-range and opens an editable transcript for it. Confirming replaces just that sub-range with fresh TTS, under any character, not necessarily the clip's own.
Click the rulerSeeks the transport to that time.
Generate (Transport dock)Once the document has clips, this regenerates every dirty clip in one pass instead of the whole document, with bounded concurrency and cancel-safe queuing. A document with no clips yet still falls back to the original whole-document pipeline. Its menu holds Auto-split then generate and the Split by paragraph toggle.
+
+
+
+ +
+
+

Transport dockPlayback

+

Bottom-right. Play / pause / stop, the position readout, a Loop toggle, then Preview / Generate / Cancel, then one progress bar that carries the status text.

+
+ + + + + + + + + +
Transport
ControlWhat it does
Play / Pause / StopPlays the whole arrangement through one sounddevice output stream that mixes every clip at its timeline position (overlaps are a plain sum, clipped to full scale). Position comes from the audio callback's frame counter, so the playhead is sample accurate. Space toggles playback anywhere except inside the text editor; Ctrl+Space toggles everywhere.
PlayheadA line across every lane plus a marker on the ruler. The transcript highlights the clip being played and scrolls to it without moving your caret. A clip that finishes generating mid-playback becomes audible without stopping.
LoopWraps to the start instead of stopping at the end of the arrangement.
PreviewSpeaks the selected text (or the first 1000 characters of the document) with the project-scope settings, through the fire-and-forget preview player.
+
+
+
+ +
+
+
+

Edit menuUndo & redo

+

Text edits ride the editor's native undo; character/FX reassignments, clip moves and FX overrides push onto a plain-Python undo stack tied to the document. Edit > Undo/Redo (or the OS shortcuts) pops whichever happened most recently. Edit > Characters... edits each character's name, color, voice and FX preset; a character still used by clips can't be removed.

+
+
+
+ +
+
+

File menuProjects

+

A project is a .tbaw bundle (text based audio workflow): one zip with the text and clips, every generated segment, and the voice mixes, voice references and FX presets it names, so it opens on another machine with the same engines installed. The layout is on the format page. Older .json projects open and are converted next to the original. Launch reopens the last project you had open, then shows the welcome dialog over it.

+
+ + + + + + + + + + + + +
File menu
ActionWhat it does
Welcome…The dialog launch shows: recent projects with the open one first and Resume as the default button, New project, New from text file, Open other, right-click a row to drop it, Clear list, and a details pane (path, modified time, character and clip counts, audio length, engines) read from the .tbaw manifest without extracting it. "Show at startup" is show_welcome in config_qt.json; untick it for a silent resume.
NewAn empty project that inherits the current project's characters (a copy, until the global character library exists).
Open / RecentOpens a project file; Recent lists the last ten.
Save / Save AsAutosave writes the app's working copy of the project (under cache/projects/), never the .tbaw; Save writes the file in the background and Save As branches to a new file. The window title shows * while the working copy is ahead of the file. Closing with unsaved changes asks Save / Discard / Cancel; after a crash, opening the project offers to recover the unsaved session.
Import TextExtracts a .txt, .pdf, or .epub and asks whether to add it at the caret or start a new project from it. The insert is one undo step.
Import AudioGreyed out for now; arrives with ASR-anchored import.
ExportThe mixdown dialog described next.
+
+
+
+ +
+
+

File > ExportExport

+

Generate only fills the per-clip cache. Export is what produces a deliverable: every clip mixed down at its timeline position into one file, using the same summing rule the transport plays. It refuses with a count when clips are out of date and offers to generate first.

+
+ + + + + + + + + + +
Export dialog
SettingWhat it does
Output folder / Base filenameWhere the mixdown is written and what it's called. Remembered per project.
Project: bundle generated audio / Bundle audio formatWhether Save puts generated segments into the .tbaw (off gives a small file whose clips regenerate on open) and whether new segments are written as wav or flac. Remembered per project.
Formatwav, mp3, flac or ogg.
Also write .srtSubtitles timed from each clip's timeline start and duration, one row per generated clip.
Keep per-clip filesWrites each clip next to the mixdown as <base>_001_<Character>.<ext>, numbered in timeline order.
+
+
+
+ +
+
+

Workspace menuWorkspaces & theme

+
+

Every panel is a dock you can drag, close and re-tab. Workspace > Advanced is the default 2x2 grid; Simple hides the timeline and gives the transcript the full height, same document, same Generate behavior; Reset layout rebuilds the active one. Whatever you drag around is saved into the active workspace and comes back with it. Options > Theme switches dark (the default) and light; the gutter, timeline and ruler read the same palette as the widgets.

+
+
+
+ +
+
+

Audio FX tabFX

+

Five groups, each section gated behind its own enable checkbox except EQ, which is always live (a slider left at 0 dB is simply a no-op). All of it runs through one Pedalboard chain together as step 4 of the signal chain. The whole chain (and volume, pitch, normalize, trim) is applied on playback and export over the clip's raw generated audio: edit anything here and the timeline plays the change, with no regeneration and no clip marked out of date. The tab follows the selection: project defaults, a character's preset, or one clip's override, layered in that order.

+
+ + + + + + + + +
Dynamics
SettingWhat it does
CompressorThreshold (dB) and Ratio. Standard dynamic-range compression.
LimiterThreshold (dB). A hard ceiling on peak level, separate from the Compressor.
GainA flat gain change in dB, applied within the FX chain, on top of the separate Volume control above.
+
+
+ + + + + + + + + +
EQ & filters
SettingWhat it does
Bass (low shelf)±20 dB shelf below 250 Hz. No separate enable toggle; 0 dB does nothing.
Treble (high shelf)±20 dB shelf above 4000 Hz. Same, no toggle needed.
High-pass filterCutoff frequency, 20-1000 Hz. Removes content below the cutoff.
Low-pass filterCutoff frequency, 1000-20000 Hz. Removes content above the cutoff.
+
+
+ + + + + + + +
Spatial & time
SettingWhat it does
ReverbRoom Size, Wet Level, Damping, Width. Standard algorithmic reverb.
DelayTime (seconds), Feedback, Mix. Echo/repeat effect.
+
+
+ + + + + + + + + +
Guitar / modulation
SettingWhat it does
ChorusRate (Hz), Depth. Layers a detuned, delayed copy of the signal.
DistortionDrive (dB). Harmonic distortion.
PhaserRate (Hz). Sweeping comb-filter effect.
ClippingThreshold (dB). Hard-clips the waveform above the threshold.
+
+
+ + + + + + + + +
Quality / pitch
SettingWhat it does
Pitch Shift (High Quality)±12 semitones. The formant-aware shift mentioned above: changes pitch without the duration side effect the plain Pitch control has.
BitcrushBit depth, 2-16. Reduces sample resolution for a lo-fi, digital-artifact sound.
GSM CompressorA single checkbox, no slider. Runs the audio through a simulated GSM phone-call codec.
+
+
+
+ +
+
+

Lexicon dockLexicon

+
+ + + + + + +
Lexicon
SettingWhat it does
Original text / ReplacementA list of literal find-and-replace rules, applied case-insensitively to every segment's text before synthesis. Useful for acronyms, names, or jargon a voice mispronounces.
+
+

Rules apply regardless of which preset or FX preset an inline script tag switches to; Lexicon is global, not per-speaker.

+
+
+ +
+
+

Mixing dockMixing supports_voice_mixing engines only

+
+ + + + + + + + + + +
Mixing
SettingWhat it does
Voice A / Voice BEach has its own language and voice picker; the two voices being combined.
OperationMix, Add, Subtract, Multiply, or Divide, applied directly to the two voices' underlying tensors.
Mix slider0-100%. For Mix, a straight blend: 0% is all Voice A, 100% is all Voice B. For the other four operations it's an influence amount, not a blend ratio, and the app's own label warns that Divide is "unstable and VERY LOUD."
Preview language / PreviewSpeaks a short preview line in the chosen language using the in-progress mix, before you commit to saving it.
New voice name / Create & SaveSaves the result as a custom voice, which then shows up in the normal Voice dropdown alongside the built-in voices.
+
+
+
+ +
+
+

Voice Reference dockVoice reference supports_voice_cloning engines only

+
+ + + + + + + + + +
Voice reference
SettingWhat it does
Reference audioBrowse to a WAV file of the voice to clone.
TranscriptWhat's actually said in that WAV, word for word. The cloning model uses this alongside the audio itself.
Auto-transcribeRuns the reference clip through the bundled ASR model to pre-fill the transcript, off the Qt main thread so the UI doesn't freeze. Always worth proofreading the result before saving.
Save as / Saved referencesSaves the WAV and transcript as a named pair, which then shows up in the Voice dropdown like any other voice.
+
+
+
+ +
+
+

PresetsPresets, characters, and how they relate to inline scripting

+
+

Two preset systems, both plain JSON on disk. presets/<name>.json files are whole-config snapshots (voice, speed, split pattern, and the rest); since 4.0 nothing in the GUI writes them any more, because Edit > Characters... is where a named voice/FX bundle is edited now, but existing files still load. "Save FX Preset..." in the Audio FX tab snapshots just the FX sliders, as presets/fx/<name>.json.

+

Every file in presets/ also becomes a Character on first load, one per file, with a highlight color assigned from a small fixed palette. If you had no presets saved yet, one "Default" character is seeded from your last-used settings instead, so a returning user's config isn't discarded. The preset files themselves are untouched either way, characters are read from them, not a replacement for them.

+

These aren't a separate feature from inline scripting, they're the same thing. A [Name]: tag in the transcript looks up a character by exactly that name, case-insensitively, and a character's name starts out as whatever the preset file it came from was called.

+
+
+
+ +
+
+

App settingsJIT streaming needs speed to spare

+
+

JIT mode plays audio back while it's still being generated: a generation thread fills a queue, a playback thread drains it. For that to work at all, generation has to outrun playback, producing more than one second of audio for every second of wall-clock time it spends generating, with enough margin that playback never catches up and stalls waiting on the next chunk. An engine that can only generate at, or slower than, real time simply can't stream: there'd be nothing queued up by the time playback needs it.

+

This is exactly what the supports_jit_streaming capability flag encodes, per engine, not measured live, just declared:

+
+
+ + + + + + + +
JIT support by engine
Enginesupports_jit_streaming
Kokoro (local)True
Audio8 TTS (voice cloning)False
+
+
+

Audio8 is a 0.6B-parameter model running through one shared, lock-serialized instance rather than a pipeline per thread, so there's no way to overlap generation the way Kokoro does. Rather than offer a JIT toggle that would just stall constantly, the app disables it outright: with Audio8 active the "JIT streaming" entry under Options is greyed out, its tooltip reading Audio8 TTS (voice cloning) doesn't support streaming - runs as Standard.. Every run on that engine falls back to Standard batch generation, automatically, no separate setting to change.

+
+
+
+
+ + + + + + diff --git a/gui.py b/gui.py deleted file mode 100644 index b47e7cd..0000000 --- a/gui.py +++ /dev/null @@ -1,1759 +0,0 @@ -import os -import time -import json -import re -import playback -import customtkinter as ctk -from tkinter import filedialog, messagebox -import threading -from kokoro_engine import KokoroEngine - -# Set Default Appearance (will be overridden by settings) -ctk.set_appearance_mode("Dark") -ctk.set_default_color_theme("blue") - -CONFIG_FILE = "config.json" -PRESETS_DIR = "presets" -FX_PRESETS_DIR = os.path.join(PRESETS_DIR, "fx") - -class TTSApp(ctk.CTk): - def __init__(self): - super().__init__() - - self.title("Kokoro TTS GUI") - self.geometry("700x900") - self.protocol("WM_DELETE_WINDOW", self.on_close) - - # Ensure presets dirs exist - if not os.path.exists(PRESETS_DIR): - os.makedirs(PRESETS_DIR) - if not os.path.exists(FX_PRESETS_DIR): - os.makedirs(FX_PRESETS_DIR) - - # Load Settings - self.settings = self.load_settings() - self.apply_settings() - - # Initialize Engine - self.engine = KokoroEngine() - self.engine.on_progress = self.on_engine_progress - self.engine.on_status = self.on_engine_status - self.engine.on_finish = self.on_engine_finish - - # Auto-save timer - self.save_timer = None - - # Variables - self.file_path_var = ctk.StringVar() - - self.LANGUAGES = { - "American English": "a", - "British English": "b", - "Spanish": "e", - "French": "f", - "Italian": "i", - "Portuguese": "p", - "Japanese": "j", - "Chinese": "z", - } - - self.VOICE_DB = { - "a": ["af_heart", "af_alloy", "af_aoede", "af_bella", "af_jessica", "af_kore", "af_nicole", "af_nova", "af_river", "af_sarah", "af_sky", "am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam", "am_michael", "am_onyx", "am_puck", "am_santa"], - "b": ["bf_alice", "bf_emma", "bf_isabella", "bf_lily", "bm_daniel", "bm_fable", "bm_george", "bm_lewis"], - "e": ["ef_dora", "em_alex", "em_santa"], - "f": ["ff_siwis"], - "i": ["if_sara", "im_nicola"], - "p": ["pf_dora", "pm_alex"], - "j": ["jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro"], - "z": ["zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zm_yunjian"] - } - - self.lang_var = ctk.StringVar(value=self.settings.get("lang_code", "a")) - - # Determine initial standard voices based on lang - self.standard_voices = self.VOICE_DB.get(self.lang_var.get(), []) - if not self.standard_voices: # Fallback - self.standard_voices = self.VOICE_DB["a"] - - self.voice_var = ctk.StringVar(value=self.settings.get("voice", "af_heart")) - self.filename_var = ctk.StringVar(value=self.settings.get("filename", "output")) - self.output_format_var = ctk.StringVar(value=self.settings.get("format", "wav")) - self.output_dir_var = ctk.StringVar(value=self.settings.get("out_dir", "audio_output")) - self.speed_var = ctk.DoubleVar(value=self.settings.get("speed", 1.0)) - self.volume_var = ctk.DoubleVar(value=self.settings.get("volume", 1.0)) - self.pitch_var = ctk.DoubleVar(value=self.settings.get("pitch", 0.0)) - self.num_threads_var = ctk.IntVar(value=self.settings.get("num_threads", 1)) - self.split_pattern_var = ctk.StringVar(value=self.settings.get("split_pattern", r"\n+")) - - self.separate_files = ctk.BooleanVar(value=self.settings.get("separate", True)) - self.combine_post = ctk.BooleanVar(value=self.settings.get("combine", True)) - self.export_subtitles = ctk.BooleanVar(value=self.settings.get("export_subtitles", False)) - self.caching_enabled = ctk.BooleanVar(value=self.settings.get("caching", True)) - self.jit_enabled = ctk.BooleanVar(value=self.settings.get("jit_enabled", False)) - self.normalize_audio = ctk.BooleanVar(value=self.settings.get("normalize", False)) - self.trim_silence = ctk.BooleanVar(value=self.settings.get("trim", False)) - self.apply_fx_var = ctk.BooleanVar(value=self.settings.get("apply_fx", True)) - self.timecode_format = "%Y%m%d%H%M%S" - - # FX Variables - self.reverb_enabled = ctk.BooleanVar(value=self.settings.get("reverb_enabled", False)) - self.reverb_room_size = ctk.DoubleVar(value=self.settings.get("reverb_room_size", 0.5)) - self.reverb_wet_level = ctk.DoubleVar(value=self.settings.get("reverb_wet_level", 0.3)) - - self.eq_bass = ctk.DoubleVar(value=self.settings.get("eq_bass", 0.0)) - self.eq_treble = ctk.DoubleVar(value=self.settings.get("eq_treble", 0.0)) - - self.comp_enabled = ctk.BooleanVar(value=self.settings.get("comp_enabled", False)) - self.comp_threshold = ctk.DoubleVar(value=self.settings.get("comp_threshold", -20.0)) - self.comp_ratio = ctk.DoubleVar(value=self.settings.get("comp_ratio", 4.0)) - self.comp_attack = ctk.DoubleVar(value=self.settings.get("comp_attack", 1.0)) - self.comp_release = ctk.DoubleVar(value=self.settings.get("comp_release", 100.0)) - - # Reverb Extended - self.reverb_damping = ctk.DoubleVar(value=self.settings.get("reverb_damping", 0.5)) - self.reverb_dry_level = ctk.DoubleVar(value=self.settings.get("reverb_dry_level", 1.0)) - self.reverb_width = ctk.DoubleVar(value=self.settings.get("reverb_width", 1.0)) - - # New FX - # Guitar - self.distortion_enabled = ctk.BooleanVar(value=self.settings.get("distortion_enabled", False)) - self.distortion_drive = ctk.DoubleVar(value=self.settings.get("distortion_drive", 25.0)) - - self.chorus_enabled = ctk.BooleanVar(value=self.settings.get("chorus_enabled", False)) - self.chorus_rate = ctk.DoubleVar(value=self.settings.get("chorus_rate", 1.0)) - self.chorus_depth = ctk.DoubleVar(value=self.settings.get("chorus_depth", 0.25)) - self.chorus_mix = ctk.DoubleVar(value=self.settings.get("chorus_mix", 0.5)) - - self.phaser_enabled = ctk.BooleanVar(value=self.settings.get("phaser_enabled", False)) - self.phaser_rate = ctk.DoubleVar(value=self.settings.get("phaser_rate", 1.0)) - self.phaser_depth = ctk.DoubleVar(value=self.settings.get("phaser_depth", 0.5)) - self.phaser_mix = ctk.DoubleVar(value=self.settings.get("phaser_mix", 0.5)) - - self.clipping_enabled = ctk.BooleanVar(value=self.settings.get("clipping_enabled", False)) - self.clipping_thresh = ctk.DoubleVar(value=self.settings.get("clipping_thresh", -6.0)) - - # Quality - self.bitcrush_enabled = ctk.BooleanVar(value=self.settings.get("bitcrush_enabled", False)) - self.bitcrush_depth = ctk.DoubleVar(value=self.settings.get("bitcrush_depth", 8.0)) - - self.gsm_enabled = ctk.BooleanVar(value=self.settings.get("gsm_enabled", False)) - - # Filters - self.highpass_enabled = ctk.BooleanVar(value=self.settings.get("highpass_enabled", False)) - self.highpass_freq = ctk.DoubleVar(value=self.settings.get("highpass_freq", 50.0)) - - self.lowpass_enabled = ctk.BooleanVar(value=self.settings.get("lowpass_enabled", False)) - self.lowpass_freq = ctk.DoubleVar(value=self.settings.get("lowpass_freq", 10000.0)) - - # Spatial - self.delay_enabled = ctk.BooleanVar(value=self.settings.get("delay_enabled", False)) - self.delay_time = ctk.DoubleVar(value=self.settings.get("delay_time", 0.5)) - self.delay_feedback = ctk.DoubleVar(value=self.settings.get("delay_feedback", 0.0)) - self.delay_mix = ctk.DoubleVar(value=self.settings.get("delay_mix", 0.5)) - - # Pitch - self.pitch_shift_enabled = ctk.BooleanVar(value=self.settings.get("pitch_shift_enabled", False)) - self.pitch_shift_semitones = ctk.DoubleVar(value=self.settings.get("pitch_shift_semitones", 0.0)) - - # Dynamics - self.limiter_enabled = ctk.BooleanVar(value=self.settings.get("limiter_enabled", False)) - self.limiter_threshold = ctk.DoubleVar(value=self.settings.get("limiter_threshold", -1.0)) - self.limiter_release = ctk.DoubleVar(value=self.settings.get("limiter_release", 100.0)) - - self.gain_enabled = ctk.BooleanVar(value=self.settings.get("gain_enabled", False)) - self.gain_db = ctk.DoubleVar(value=self.settings.get("gain_db", 0.0)) - - # Mixing Variables - self.mix_lang_a_var = ctk.StringVar(value="a") - self.mix_lang_b_var = ctk.StringVar(value="a") - self.preview_lang_var = ctk.StringVar(value="a") - - self.mix_voice_a_var = ctk.StringVar(value=self.VOICE_DB["a"][0]) - self.mix_voice_b_var = ctk.StringVar(value=self.VOICE_DB["a"][1]) - self.mix_ratio_var = ctk.DoubleVar(value=0.5) - self.mix_op_var = ctk.StringVar(value="mix") - self.mix_name_var = ctk.StringVar() - - # Setup Auto-save Traces - self.setup_autosave() - - self.create_widgets() - - # Init Pipeline - self.status_label.configure(text="Initializing engine...") - self.engine.worker.run_coro(self.engine.init_pipeline_async(self.lang_var.get())) - - def get_all_voices(self, lang_code=None): - if lang_code is None: - lang_code = self.lang_var.get() - - standard = self.VOICE_DB.get(lang_code, []) - custom = [] - if os.path.exists("custom_voices"): - custom = [f[:-3] for f in os.listdir("custom_voices") if f.endswith(".pt")] - return sorted(standard + custom) - - - def setup_autosave(self): - vars_to_trace = [ - self.lang_var, - self.voice_var, self.filename_var, self.output_format_var, self.output_dir_var, - self.speed_var, self.volume_var, self.pitch_var, - self.num_threads_var, self.split_pattern_var, - self.separate_files, self.combine_post, self.export_subtitles, self.caching_enabled, - self.normalize_audio, self.trim_silence, self.apply_fx_var, - self.reverb_enabled, self.reverb_room_size, self.reverb_wet_level, self.reverb_damping, self.reverb_dry_level, self.reverb_width, - self.eq_bass, self.eq_treble, - self.comp_enabled, self.comp_threshold, self.comp_ratio, self.comp_attack, self.comp_release, - self.distortion_enabled, self.distortion_drive, - self.chorus_enabled, self.chorus_rate, self.chorus_depth, self.chorus_mix, - self.phaser_enabled, self.phaser_rate, self.phaser_depth, self.phaser_mix, - self.clipping_enabled, self.clipping_thresh, - self.bitcrush_enabled, self.bitcrush_depth, - self.gsm_enabled, - self.highpass_enabled, self.highpass_freq, - self.lowpass_enabled, self.lowpass_freq, - self.delay_enabled, self.delay_time, self.delay_feedback, self.delay_mix, - self.pitch_shift_enabled, self.pitch_shift_semitones, - self.limiter_enabled, self.limiter_threshold, self.limiter_release, - self.gain_enabled, self.gain_db - ] - for v in vars_to_trace: - v.trace_add("write", self.schedule_save) - - # Also trigger voice list update when lang changes - self.lang_var.trace_add("write", self.on_lang_change) - - # Mix tab traces - self.mix_lang_a_var.trace_add("write", self.on_mix_lang_a_change) - self.mix_lang_b_var.trace_add("write", self.on_mix_lang_b_change) - - def on_lang_change(self, *args): - code = self.lang_var.get() - self.standard_voices = self.VOICE_DB.get(code, self.VOICE_DB["a"]) - # Update generation combo - if hasattr(self, 'voice_combo'): - self.voice_combo.configure(values=self.get_all_voices(code)) - - # Set default voice for this language if current voice is invalid - if self.voice_var.get() not in self.VOICE_DB.get(code, []): - if self.VOICE_DB.get(code, []): - self.voice_var.set(self.VOICE_DB[code][0]) - - def _update_mix_voice_list(self, lang_var, combo_attr, voice_var): - code = lang_var.get() - if hasattr(self, combo_attr): - combo = getattr(self, combo_attr) - voices = self.get_all_voices(code) - combo.configure(values=voices) - if voice_var.get() not in voices: - voice_var.set(voices[0]) - - def on_mix_lang_a_change(self, *args): - self._update_mix_voice_list(self.mix_lang_a_var, 'mix_combo_a', self.mix_voice_a_var) - - def on_mix_lang_b_change(self, *args): - self._update_mix_voice_list(self.mix_lang_b_var, 'mix_combo_b', self.mix_voice_b_var) - - def schedule_save(self, *args): - if self.save_timer: - self.after_cancel(self.save_timer) - self.save_timer = self.after(1000, self.save_settings) - - def load_settings(self): - defaults = { - "appearance": "Dark", - "scaling": "100%", - "lang_code": "a", - "voice": "af_heart", - "filename": "output", - "format": "wav", - "out_dir": "audio_output", - "speed": 1.0, - "volume": 1.0, - "pitch": 0.0, - "num_threads": 1, - "split_pattern": r"\n+", - "separate": True, - "combine": True, - "export_subtitles": False, - "caching": True, - "jit_enabled": False, - "normalize": False, - "trim": False, - "apply_fx": True, - "reverb_enabled": False, - "reverb_room_size": 0.5, - "reverb_wet_level": 0.3, - "reverb_damping": 0.5, - "reverb_dry_level": 1.0, - "reverb_width": 1.0, - "eq_bass": 0.0, - "eq_treble": 0.0, - "comp_enabled": False, - "comp_threshold": -20.0, - "comp_ratio": 4.0, - "comp_attack": 1.0, - "comp_release": 100.0, - "distortion_enabled": False, - "distortion_drive": 25.0, - "chorus_enabled": False, - "chorus_rate": 1.0, - "chorus_depth": 0.25, - "chorus_mix": 0.5, - "phaser_enabled": False, - "phaser_rate": 1.0, - "phaser_depth": 0.5, - "phaser_mix": 0.5, - "clipping_enabled": False, - "clipping_thresh": -6.0, - "bitcrush_enabled": False, - "bitcrush_depth": 8.0, - "gsm_enabled": False, - "highpass_enabled": False, - "highpass_freq": 50.0, - "lowpass_enabled": False, - "lowpass_freq": 10000.0, - "delay_enabled": False, - "delay_time": 0.5, - "delay_feedback": 0.0, - "delay_mix": 0.5, - "pitch_shift_enabled": False, - "pitch_shift_semitones": 0.0, - "limiter_enabled": False, - "limiter_threshold": -1.0, - "limiter_release": 100.0, - "gain_enabled": False, - "gain_db": 0.0, - "lexicon": {} - } - if os.path.exists(CONFIG_FILE): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - return {**defaults, **json.load(f)} - except Exception: - pass - return defaults - - def save_settings(self): - if self.save_timer: - self.after_cancel(self.save_timer) - self.save_timer = None - - if hasattr(self, 'voice_var'): - self.settings['lang_code'] = self.lang_var.get() - self.settings['voice'] = self.voice_var.get() - self.settings['filename'] = self.filename_var.get() - self.settings['format'] = self.output_format_var.get() - self.settings['out_dir'] = self.output_dir_var.get() - self.settings['speed'] = self.speed_var.get() - self.settings['volume'] = self.volume_var.get() - self.settings['pitch'] = self.pitch_var.get() - self.settings['num_threads'] = self.num_threads_var.get() - self.settings['split_pattern'] = self.split_pattern_var.get() - self.settings['separate'] = self.separate_files.get() - self.settings['combine'] = self.combine_post.get() - self.settings['export_subtitles'] = self.export_subtitles.get() - self.settings['caching'] = self.caching_enabled.get() - self.settings['jit_enabled'] = self.jit_enabled.get() - self.settings['normalize'] = self.normalize_audio.get() - self.settings['trim'] = self.trim_silence.get() - self.settings['apply_fx'] = self.apply_fx_var.get() - self.settings['reverb_enabled'] = self.reverb_enabled.get() - self.settings['reverb_room_size'] = self.reverb_room_size.get() - self.settings['reverb_wet_level'] = self.reverb_wet_level.get() - self.settings['reverb_damping'] = self.reverb_damping.get() - self.settings['reverb_dry_level'] = self.reverb_dry_level.get() - self.settings['reverb_width'] = self.reverb_width.get() - - self.settings['eq_bass'] = self.eq_bass.get() - self.settings['eq_treble'] = self.eq_treble.get() - - self.settings['comp_enabled'] = self.comp_enabled.get() - self.settings['comp_threshold'] = self.comp_threshold.get() - self.settings['comp_ratio'] = self.comp_ratio.get() - self.settings['comp_attack'] = self.comp_attack.get() - self.settings['comp_release'] = self.comp_release.get() - - self.settings['distortion_enabled'] = self.distortion_enabled.get() - self.settings['distortion_drive'] = self.distortion_drive.get() - - self.settings['chorus_enabled'] = self.chorus_enabled.get() - self.settings['chorus_rate'] = self.chorus_rate.get() - self.settings['chorus_depth'] = self.chorus_depth.get() - self.settings['chorus_mix'] = self.chorus_mix.get() - - self.settings['phaser_enabled'] = self.phaser_enabled.get() - self.settings['phaser_rate'] = self.phaser_rate.get() - self.settings['phaser_depth'] = self.phaser_depth.get() - self.settings['phaser_mix'] = self.phaser_mix.get() - - self.settings['clipping_enabled'] = self.clipping_enabled.get() - self.settings['clipping_thresh'] = self.clipping_thresh.get() - - self.settings['bitcrush_enabled'] = self.bitcrush_enabled.get() - self.settings['bitcrush_depth'] = self.bitcrush_depth.get() - - self.settings['gsm_enabled'] = self.gsm_enabled.get() - - self.settings['highpass_enabled'] = self.highpass_enabled.get() - self.settings['highpass_freq'] = self.highpass_freq.get() - - self.settings['lowpass_enabled'] = self.lowpass_enabled.get() - self.settings['lowpass_freq'] = self.lowpass_freq.get() - - self.settings['delay_enabled'] = self.delay_enabled.get() - self.settings['delay_time'] = self.delay_time.get() - self.settings['delay_feedback'] = self.delay_feedback.get() - self.settings['delay_mix'] = self.delay_mix.get() - - self.settings['pitch_shift_enabled'] = self.pitch_shift_enabled.get() - self.settings['pitch_shift_semitones'] = self.pitch_shift_semitones.get() - - self.settings['limiter_enabled'] = self.limiter_enabled.get() - self.settings['limiter_threshold'] = self.limiter_threshold.get() - self.settings['limiter_release'] = self.limiter_release.get() - - self.settings['gain_enabled'] = self.gain_enabled.get() - self.settings['gain_db'] = self.gain_db.get() - - try: - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(self.settings, f, indent=4) - except Exception as e: - print(f"Failed to save settings: {e}") - - def apply_settings(self): - ctk.set_appearance_mode(self.settings["appearance"]) - - # Parse scaling - scale_str = self.settings["scaling"].replace("%", "") - try: - scale_float = float(scale_str) / 100 - ctk.set_widget_scaling(scale_float) - except Exception: - ctk.set_widget_scaling(1.0) - - # --- Preset Management --- - - def refresh_presets(self): - presets = ["Select Preset..."] - if os.path.exists(PRESETS_DIR): - files = [f for f in os.listdir(PRESETS_DIR) if f.endswith(".json")] - presets.extend([f[:-5] for f in files]) # Remove .json - - self.preset_combo.configure(values=presets) - self.preset_combo.set("Select Preset...") - - def save_preset_dialog(self): - dialog = ctk.CTkInputDialog(text="Enter preset name:", title="Save Preset") - name = dialog.get_input() - if name: - name = re.sub(r'[<>:"/\\|?*]', '', name).strip() # Sanitize - if not name: return - - data = { - "voice": self.voice_var.get(), - "speed": self.speed_var.get(), - "volume": self.volume_var.get(), - "pitch": self.pitch_var.get(), - "split_pattern": self.split_pattern_var.get(), - "normalize": self.normalize_audio.get(), - "trim": self.trim_silence.get(), - "format": self.output_format_var.get(), - "apply_fx": self.apply_fx_var.get(), - "fx_preset": self.gen_fx_combo.get() - } - - fpath = os.path.join(PRESETS_DIR, f"{name}.json") - try: - with open(fpath, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - messagebox.showinfo("Saved", f"Preset '{name}' saved successfully.") - self.refresh_presets() - self.preset_combo.set(name) - except Exception as e: - messagebox.showerror("Error", f"Failed to save preset: {e}") - - def load_preset(self, name): - if name == "Select Preset...": return - - fpath = os.path.join(PRESETS_DIR, f"{name}.json") - if os.path.exists(fpath): - try: - with open(fpath, "r", encoding="utf-8") as f: - data = json.load(f) - - if "voice" in data: self.voice_var.set(data["voice"]) - if "speed" in data: self.speed_var.set(data["speed"]) - if "volume" in data: self.volume_var.set(data["volume"]) - if "pitch" in data: self.pitch_var.set(data["pitch"]) - if "split_pattern" in data: self.split_pattern_var.set(data["split_pattern"]) - if "normalize" in data: self.normalize_audio.set(data["normalize"]) - if "trim" in data: self.trim_silence.set(data["trim"]) - if "format" in data: self.output_format_var.set(data["format"]) - if "apply_fx" in data: self.apply_fx_var.set(data["apply_fx"]) - - if "fx_preset" in data: - fx_name = data["fx_preset"] - if fx_name and fx_name != "Select FX Preset...": - self.load_fx_preset(fx_name) - # Ensure combo is updated (load_fx_preset does this, but being safe) - if hasattr(self, 'gen_fx_combo'): self.gen_fx_combo.set(fx_name) - - # Update UI labels manually since setting var triggers trace but maybe not UI update logic dependent on callbacks - self.update_audio_labels(0) - self.update_speed_label(self.speed_var.get()) - - # Update split combo logic - target_pat = self.split_pattern_var.get() - for k, v in self.split_map.items(): - if v == target_pat: - self.split_combo.set(k) - break - - except Exception as e: - messagebox.showerror("Error", f"Failed to load preset: {e}") - - # --- FX Preset Management --- - - def refresh_fx_presets(self): - presets = ["Select FX Preset..."] - if os.path.exists(FX_PRESETS_DIR): - files = [f for f in os.listdir(FX_PRESETS_DIR) if f.endswith(".json")] - presets.extend([f[:-5] for f in files]) # Remove .json - - # Update FX Tab Combo - if hasattr(self, 'fx_preset_combo'): - self.fx_preset_combo.configure(values=presets) - self.fx_preset_combo.set("Select FX Preset...") - - # Update Gen Tab Combo - if hasattr(self, 'gen_fx_combo'): - self.gen_fx_combo.configure(values=presets) - self.gen_fx_combo.set("Select FX Preset...") - - def save_fx_preset_dialog(self): - dialog = ctk.CTkInputDialog(text="Enter FX preset name:", title="Save FX Preset") - name = dialog.get_input() - if name: - name = re.sub(r'[<>:"/\\|?*]', '', name).strip() - if not name: return - - data = { - "reverb_enabled": self.reverb_enabled.get(), - "reverb_room_size": self.reverb_room_size.get(), - "reverb_wet_level": self.reverb_wet_level.get(), - "reverb_damping": self.reverb_damping.get(), - "reverb_dry_level": self.reverb_dry_level.get(), - "reverb_width": self.reverb_width.get(), - "eq_bass": self.eq_bass.get(), - "eq_treble": self.eq_treble.get(), - "comp_enabled": self.comp_enabled.get(), - "comp_threshold": self.comp_threshold.get(), - "comp_ratio": self.comp_ratio.get(), - "comp_attack": self.comp_attack.get(), - "comp_release": self.comp_release.get(), - "distortion_enabled": self.distortion_enabled.get(), - "distortion_drive": self.distortion_drive.get(), - "chorus_enabled": self.chorus_enabled.get(), - "chorus_rate": self.chorus_rate.get(), - "chorus_depth": self.chorus_depth.get(), - "chorus_mix": self.chorus_mix.get(), - "phaser_enabled": self.phaser_enabled.get(), - "phaser_rate": self.phaser_rate.get(), - "phaser_depth": self.phaser_depth.get(), - "phaser_mix": self.phaser_mix.get(), - "clipping_enabled": self.clipping_enabled.get(), - "clipping_thresh": self.clipping_thresh.get(), - "bitcrush_enabled": self.bitcrush_enabled.get(), - "bitcrush_depth": self.bitcrush_depth.get(), - "gsm_enabled": self.gsm_enabled.get(), - "highpass_enabled": self.highpass_enabled.get(), - "highpass_freq": self.highpass_freq.get(), - "lowpass_enabled": self.lowpass_enabled.get(), - "lowpass_freq": self.lowpass_freq.get(), - "delay_enabled": self.delay_enabled.get(), - "delay_time": self.delay_time.get(), - "delay_feedback": self.delay_feedback.get(), - "delay_mix": self.delay_mix.get(), - "pitch_shift_enabled": self.pitch_shift_enabled.get(), - "pitch_shift_semitones": self.pitch_shift_semitones.get(), - "limiter_enabled": self.limiter_enabled.get(), - "limiter_threshold": self.limiter_threshold.get(), - "limiter_release": self.limiter_release.get(), - "gain_enabled": self.gain_enabled.get(), - "gain_db": self.gain_db.get() - } - - fpath = os.path.join(FX_PRESETS_DIR, f"{name}.json") - try: - with open(fpath, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4) - messagebox.showinfo("Saved", f"FX Preset '{name}' saved.") - self.refresh_fx_presets() - if hasattr(self, 'fx_preset_combo'): self.fx_preset_combo.set(name) - if hasattr(self, 'gen_fx_combo'): self.gen_fx_combo.set(name) - except Exception as e: - messagebox.showerror("Error", f"Failed to save FX preset: {e}") - - def load_fx_preset(self, name): - if name == "Select FX Preset...": return - - safe_name = os.path.basename(name) - if not safe_name: return - fpath = os.path.join(FX_PRESETS_DIR, f"{safe_name}.json") - if os.path.exists(fpath): - try: - with open(fpath, "r", encoding="utf-8") as f: - data = json.load(f) - - if "reverb_enabled" in data: self.reverb_enabled.set(data["reverb_enabled"]) - if "reverb_room_size" in data: self.reverb_room_size.set(data["reverb_room_size"]) - if "reverb_wet_level" in data: self.reverb_wet_level.set(data["reverb_wet_level"]) - if "reverb_damping" in data: self.reverb_damping.set(data["reverb_damping"]) - if "reverb_dry_level" in data: self.reverb_dry_level.set(data["reverb_dry_level"]) - if "reverb_width" in data: self.reverb_width.set(data["reverb_width"]) - - if "eq_bass" in data: self.eq_bass.set(data["eq_bass"]) - if "eq_treble" in data: self.eq_treble.set(data["eq_treble"]) - - if "comp_enabled" in data: self.comp_enabled.set(data["comp_enabled"]) - if "comp_threshold" in data: self.comp_threshold.set(data["comp_threshold"]) - if "comp_ratio" in data: self.comp_ratio.set(data["comp_ratio"]) - if "comp_attack" in data: self.comp_attack.set(data["comp_attack"]) - if "comp_release" in data: self.comp_release.set(data["comp_release"]) - - if "distortion_enabled" in data: self.distortion_enabled.set(data["distortion_enabled"]) - if "distortion_drive" in data: self.distortion_drive.set(data["distortion_drive"]) - - if "chorus_enabled" in data: self.chorus_enabled.set(data["chorus_enabled"]) - if "chorus_rate" in data: self.chorus_rate.set(data["chorus_rate"]) - if "chorus_depth" in data: self.chorus_depth.set(data["chorus_depth"]) - if "chorus_mix" in data: self.chorus_mix.set(data["chorus_mix"]) - - if "phaser_enabled" in data: self.phaser_enabled.set(data["phaser_enabled"]) - if "phaser_rate" in data: self.phaser_rate.set(data["phaser_rate"]) - if "phaser_depth" in data: self.phaser_depth.set(data["phaser_depth"]) - if "phaser_mix" in data: self.phaser_mix.set(data["phaser_mix"]) - - if "clipping_enabled" in data: self.clipping_enabled.set(data["clipping_enabled"]) - if "clipping_thresh" in data: self.clipping_thresh.set(data["clipping_thresh"]) - - if "bitcrush_enabled" in data: self.bitcrush_enabled.set(data["bitcrush_enabled"]) - if "bitcrush_depth" in data: self.bitcrush_depth.set(data["bitcrush_depth"]) - - if "gsm_enabled" in data: self.gsm_enabled.set(data["gsm_enabled"]) - - if "highpass_enabled" in data: self.highpass_enabled.set(data["highpass_enabled"]) - if "highpass_freq" in data: self.highpass_freq.set(data["highpass_freq"]) - - if "lowpass_enabled" in data: self.lowpass_enabled.set(data["lowpass_enabled"]) - if "lowpass_freq" in data: self.lowpass_freq.set(data["lowpass_freq"]) - - if "delay_enabled" in data: self.delay_enabled.set(data["delay_enabled"]) - if "delay_time" in data: self.delay_time.set(data["delay_time"]) - if "delay_feedback" in data: self.delay_feedback.set(data["delay_feedback"]) - if "delay_mix" in data: self.delay_mix.set(data["delay_mix"]) - - if "pitch_shift_enabled" in data: self.pitch_shift_enabled.set(data["pitch_shift_enabled"]) - if "pitch_shift_semitones" in data: self.pitch_shift_semitones.set(data["pitch_shift_semitones"]) - - if "limiter_enabled" in data: self.limiter_enabled.set(data["limiter_enabled"]) - if "limiter_threshold" in data: self.limiter_threshold.set(data["limiter_threshold"]) - if "limiter_release" in data: self.limiter_release.set(data["limiter_release"]) - - if "gain_enabled" in data: self.gain_enabled.set(data["gain_enabled"]) - if "gain_db" in data: self.gain_db.set(data["gain_db"]) - - self.update_fx_labels() - - # Sync Combos - if hasattr(self, 'fx_preset_combo'): self.fx_preset_combo.set(name) - if hasattr(self, 'gen_fx_combo'): self.gen_fx_combo.set(name) - - except Exception as e: - messagebox.showerror("Error", f"Failed to load FX preset: {e}") - - def refresh_voice_lists(self): - # Update Gen Tab Combo - if hasattr(self, 'voice_combo'): - self.voice_combo.configure(values=self.get_all_voices(self.lang_var.get())) - - # Update Mix Tab Combos - self.on_mix_lang_a_change() - self.on_mix_lang_b_change() - - # Update Custom List - if hasattr(self, 'custom_list_frame'): - for widget in self.custom_list_frame.winfo_children(): - widget.destroy() - - all_voices = self.get_all_voices(self.lang_var.get()) - custom = [f[:-3] for f in os.listdir("custom_voices") if f.endswith(".pt")] - if not custom: - ctk.CTkLabel(self.custom_list_frame, text="No custom voices found.", text_color="gray").pack(pady=5) - else: - for cv in sorted(custom): - row = ctk.CTkFrame(self.custom_list_frame) - row.pack(fill="x", pady=2) - ctk.CTkLabel(row, text=cv).pack(side="left", padx=5) - ctk.CTkButton(row, text="X", width=30, fg_color="#c42b1c", command=lambda v=cv: self.delete_custom_voice(v)).pack(side="right", padx=5) - - def delete_custom_voice(self, name): - if messagebox.askyesno("Confirm", f"Delete voice '{name}'?"): - try: - path = os.path.join("custom_voices", f"{name}.pt") - if os.path.exists(path): - os.remove(path) - self.refresh_voice_lists() - except Exception as e: - messagebox.showerror("Error", f"Failed to delete: {e}") - - def preview_mix(self): - v1 = self.mix_voice_a_var.get() - v2 = self.mix_voice_b_var.get() - ratio = self.mix_ratio_var.get() - op = self.mix_op_var.get() - preview_lang = self.preview_lang_var.get() - - preview_text = "This is a preview of your custom mixed voice." - if preview_lang == 'f': preview_text = "Ceci est un aperçu de votre voix personnalisée." - elif preview_lang == 'e': preview_text = "Esta es una vista previa de su voz personalizada." - elif preview_lang == 'i': preview_text = "Questa è un'anteprima della tua voce personalizzata." - elif preview_lang == 'p': preview_text = "Esta é uma prévia da sua voz personalizada." - elif preview_lang == 'j': preview_text = "これはカスタム合成音声のプレビューです。" - elif preview_lang == 'z': preview_text = "这是您的自定义混合语音预览。" - - # Temp voice name and file - import tempfile - tmp_voice_name = "_tmp_mix_preview" - tmp_audio_path = os.path.join(tempfile.gettempdir(), "kokoro_mix_preview.wav") - - self.mix_status_label.configure(text="Generating preview...", text_color="blue") - - async def _run_preview(): - # 1. Mix to a temporary file (we ignore the file for preview, use tensor) - success, msg, tensor = await self.engine.mix_voices(v1, v2, ratio, tmp_voice_name, op=op) - if not success: - return False, msg - - # 2. Generate audio using that mixed voice tensor and target preview language - success = await self.engine.generate_preview(preview_text, tmp_voice_name, 1.0, tmp_audio_path, voice_tensor=tensor, lang_code=preview_lang) - - # 3. Cleanup temp voice file - try: - p = os.path.join("custom_voices", f"{tmp_voice_name}.pt") - if os.path.exists(p): os.remove(p) - except Exception: pass - - return success, "" - - def _on_done(future): - try: - success, err = future.result() - if success: - self.after(0, lambda: self.mix_status_label.configure(text="Playing preview...", text_color="green")) - playback.play(tmp_audio_path) - else: - self.after(0, lambda: self.mix_status_label.configure(text=f"Preview failed: {err}", text_color="red")) - except Exception as e: - self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {e}", text_color="red")) - - future = self.engine.worker.run_coro(_run_preview()) - future.add_done_callback(_on_done) - - def mix_voice_action(self): - v1 = self.mix_voice_a_var.get() - v2 = self.mix_voice_b_var.get() - ratio = self.mix_ratio_var.get() - op = self.mix_op_var.get() - name = self.mix_name_var.get().strip() - - if not name: - messagebox.showwarning("Error", "Please enter a name for the new voice.") - return - - if not re.match(r'^[a-zA-Z0-9_-]+$', name): - messagebox.showwarning("Error", "Invalid name. Use alphanumeric, _, - only.") - return - - if name in self.get_all_voices(): - if not messagebox.askyesno("Overwrite", f"Voice '{name}' exists. Overwrite?"): - return - - self.mix_status_label.configure(text="Mixing...", text_color="blue") - self.set_ui_state(True) # Reuse existing lock - - def _done(future): - self.after(0, lambda: self.set_ui_state(False)) - try: - success, msg, _ = future.result() - if success: - self.after(0, lambda: self.mix_status_label.configure(text=f"Saved: {name}", text_color="green")) - self.after(0, self.refresh_voice_lists) - else: - self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {msg}", text_color="red")) - except Exception as e: - self.after(0, lambda: self.mix_status_label.configure(text=f"Error: {e}", text_color="red")) - - future = self.engine.worker.run_coro(self.engine.mix_voices(v1, v2, ratio, name, op=op)) - future.add_done_callback(_done) - - def build_mixing_tab(self, parent): - parent.grid_columnconfigure(0, weight=1) - - lang_display_map = {v: k for k, v in self.LANGUAGES.items()} - - # 1. Selection - sel_frame = ctk.CTkFrame(parent) - sel_frame.pack(fill="x", padx=10, pady=10) - sel_frame.grid_columnconfigure(1, weight=1) - sel_frame.grid_columnconfigure(2, weight=1) - - # Voice A Row - ctk.CTkLabel(sel_frame, text="Voice A:").grid(row=0, column=0, padx=10, pady=5) - - def on_lang_a_ui(c): self.mix_lang_a_var.set(self.LANGUAGES[c]) - mix_lang_a_combo = ctk.CTkComboBox(sel_frame, values=list(self.LANGUAGES.keys()), command=on_lang_a_ui, width=150) - mix_lang_a_combo.set(lang_display_map.get(self.mix_lang_a_var.get(), "American English")) - mix_lang_a_combo.grid(row=0, column=1, padx=5, pady=5, sticky="ew") - - self.mix_combo_a = ctk.CTkComboBox(sel_frame, variable=self.mix_voice_a_var) - self.mix_combo_a.grid(row=0, column=2, sticky="ew", padx=5, pady=5) - - # Voice B Row - ctk.CTkLabel(sel_frame, text="Voice B:").grid(row=1, column=0, padx=10, pady=5) - - def on_lang_b_ui(c): self.mix_lang_b_var.set(self.LANGUAGES[c]) - mix_lang_b_combo = ctk.CTkComboBox(sel_frame, values=list(self.LANGUAGES.keys()), command=on_lang_b_ui, width=150) - mix_lang_b_combo.set(lang_display_map.get(self.mix_lang_b_var.get(), "American English")) - mix_lang_b_combo.grid(row=1, column=1, padx=5, pady=5, sticky="ew") - - self.mix_combo_b = ctk.CTkComboBox(sel_frame, variable=self.mix_voice_b_var) - self.mix_combo_b.grid(row=1, column=2, sticky="ew", padx=5, pady=5) - - # 2. Ratio & Operation - ratio_frame = ctk.CTkFrame(parent) - ratio_frame.pack(fill="x", padx=10, pady=10) - - op_frame = ctk.CTkFrame(ratio_frame, fg_color="transparent") - op_frame.pack(fill="x", padx=20, pady=(10, 0)) - ctk.CTkLabel(op_frame, text="Operation:").pack(side="left", padx=5) - - def update_ratio_label(val=None): - if val is None: val = self.mix_ratio_var.get() - p = int(float(val) * 100) - op = self.mix_op_var.get() - if op == 'mix': - self.ratio_label.configure(text=f"Mix: {100-p}% A / {p}% B", text_color=("black", "white")) - elif op == 'divide': - self.ratio_label.configure(text=f"Op: Divide | Influence: {p}%\n(Results are more likely to be unstable and VERY LOUD)", text_color="#E57373") - else: - self.ratio_label.configure(text=f"Op: {op.capitalize()} | Influence: {p}%", text_color=("black", "white")) - - ctk.CTkComboBox(op_frame, values=["mix", "add", "subtract", "multiply", "divide"], variable=self.mix_op_var, command=lambda _: update_ratio_label()).pack(side="left", padx=5) - - self.ratio_label = ctk.CTkLabel(ratio_frame, text="Mix: 50% A / 50% B") - self.ratio_label.pack(pady=5) - - slider = ctk.CTkSlider(ratio_frame, from_=0.0, to=1.0, number_of_steps=100, variable=self.mix_ratio_var, command=update_ratio_label) - slider.pack(fill="x", padx=20, pady=10) - - update_ratio_label() - - # 3. Preview Lang & Actions - act_frame = ctk.CTkFrame(parent) - act_frame.pack(fill="x", padx=10, pady=10) - - ctk.CTkLabel(act_frame, text="Preview Language:").grid(row=0, column=0, padx=10, pady=5) - - def on_prev_lang_ui(c): self.preview_lang_var.set(self.LANGUAGES[c]) - prev_lang_combo = ctk.CTkComboBox(act_frame, values=list(self.LANGUAGES.keys()), command=on_prev_lang_ui, width=150) - prev_lang_combo.set(lang_display_map.get(self.preview_lang_var.get(), "American English")) - prev_lang_combo.grid(row=0, column=1, padx=5, pady=5) - - ctk.CTkButton(act_frame, text="🔊 Preview", width=100, fg_color="#2B719E", command=self.preview_mix).grid(row=0, column=2, padx=10) - - # Save Row - save_frame = ctk.CTkFrame(parent) - save_frame.pack(fill="x", padx=10, pady=10) - - ctk.CTkLabel(save_frame, text="New Voice Name:").pack(side="left", padx=10) - ctk.CTkEntry(save_frame, textvariable=self.mix_name_var).pack(side="left", fill="x", expand=True, padx=5) - ctk.CTkButton(save_frame, text="Create & Save", command=self.mix_voice_action).pack(side="left", padx=10) - - self.mix_status_label = ctk.CTkLabel(parent, text="", text_color="gray") - self.mix_status_label.pack(pady=5) - - # 4. List - ctk.CTkLabel(parent, text="Custom Voices:", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=(20,5)) - self.custom_list_frame = ctk.CTkScrollableFrame(parent, height=200) - self.custom_list_frame.pack(fill="x", padx=10, pady=5) - - self.refresh_voice_lists() - - def build_fx_tab(self, parent): - parent.grid_columnconfigure(0, weight=1) - - # --- Preset Controls --- - pre_frame = ctk.CTkFrame(parent, fg_color="transparent") - pre_frame.pack(fill="x", padx=10, pady=(10,5)) - - self.fx_preset_combo = ctk.CTkComboBox(pre_frame, values=["Select FX Preset..."], command=self.load_fx_preset, width=200) - self.fx_preset_combo.pack(side="left", padx=(0,5)) - - ctk.CTkButton(pre_frame, text="💾 Save", width=60, command=self.save_fx_preset_dialog).pack(side="left", padx=2) - ctk.CTkButton(pre_frame, text="🔄", width=30, command=self.refresh_fx_presets).pack(side="left", padx=2) - - scroll = ctk.CTkScrollableFrame(parent) - scroll.pack(fill="both", expand=True, padx=5, pady=5) - scroll.grid_columnconfigure(0, weight=1) - - # Helper to create rows - def _create_slider(parent, label_text, variable, from_, to_, steps=100, label_attr=None): - row = ctk.CTkFrame(parent, fg_color="transparent") - row.pack(fill="x", padx=5, pady=2) - lbl = ctk.CTkLabel(row, text=label_text, width=120, anchor="w") - lbl.pack(side="left") - if label_attr: setattr(self, label_attr, lbl) - - ctk.CTkSlider(row, from_=from_, to=to_, number_of_steps=steps, variable=variable, - command=lambda v: self.update_fx_labels()).pack(side="left", fill="x", expand=True, padx=5) - - # --- 1. Dynamics --- - dyn_frame = ctk.CTkFrame(scroll) - dyn_frame.pack(fill="x", padx=5, pady=5) - - ctk.CTkLabel(dyn_frame, text="Dynamics", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - # Compressor - c_head = ctk.CTkFrame(dyn_frame, fg_color="transparent") - c_head.pack(fill="x", padx=5) - ctk.CTkCheckBox(c_head, text="Compressor", variable=self.comp_enabled, font=("Roboto", 12, "bold")).pack(side="left") - - c_body = ctk.CTkFrame(dyn_frame) - c_body.pack(fill="x", padx=10, pady=2) - _create_slider(c_body, "Threshold", self.comp_threshold, -60, 0, 60, 'comp_thresh_label') - _create_slider(c_body, "Ratio", self.comp_ratio, 1, 20, 19, 'comp_ratio_label') - - # Limiter - l_head = ctk.CTkFrame(dyn_frame, fg_color="transparent") - l_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(l_head, text="Limiter", variable=self.limiter_enabled, font=("Roboto", 12, "bold")).pack(side="left") - - l_body = ctk.CTkFrame(dyn_frame) - l_body.pack(fill="x", padx=10, pady=2) - _create_slider(l_body, "Threshold", self.limiter_threshold, -12, 0, 24, 'lim_thresh_label') - - # Gain - g_head = ctk.CTkFrame(dyn_frame, fg_color="transparent") - g_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(g_head, text="Gain", variable=self.gain_enabled, font=("Roboto", 12, "bold")).pack(side="left") - _create_slider(dyn_frame, "dB", self.gain_db, -20, 20, 80, 'gain_label') - - # --- 2. EQ & Filters --- - eq_frame = ctk.CTkFrame(scroll) - eq_frame.pack(fill="x", padx=5, pady=5) - ctk.CTkLabel(eq_frame, text="EQ & Filters", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - _create_slider(eq_frame, "Bass (LowShelf)", self.eq_bass, -20, 20, 40, 'bass_label') - _create_slider(eq_frame, "Treble (HighShelf)", self.eq_treble, -20, 20, 40, 'treble_label') - - # HPF - h_head = ctk.CTkFrame(eq_frame, fg_color="transparent") - h_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(h_head, text="HighPass Filter", variable=self.highpass_enabled).pack(side="left") - _create_slider(eq_frame, "Freq (Hz)", self.highpass_freq, 20, 1000, 100, 'hpf_label') - - # LPF - lpf_head = ctk.CTkFrame(eq_frame, fg_color="transparent") - lpf_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(lpf_head, text="LowPass Filter", variable=self.lowpass_enabled).pack(side="left") - _create_slider(eq_frame, "Freq (Hz)", self.lowpass_freq, 1000, 20000, 100, 'lpf_label') - - # --- 3. Spatial & Time --- - sp_frame = ctk.CTkFrame(scroll) - sp_frame.pack(fill="x", padx=5, pady=5) - ctk.CTkLabel(sp_frame, text="Spatial & Time", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - # Reverb - r_head = ctk.CTkFrame(sp_frame, fg_color="transparent") - r_head.pack(fill="x", padx=5) - ctk.CTkCheckBox(r_head, text="Reverb", variable=self.reverb_enabled, font=("Roboto", 12, "bold")).pack(side="left") - - r_body = ctk.CTkFrame(sp_frame) - r_body.pack(fill="x", padx=10, pady=2) - _create_slider(r_body, "Room Size", self.reverb_room_size, 0, 1, 100, 'rev_room_label') - _create_slider(r_body, "Wet Level", self.reverb_wet_level, 0, 1, 100, 'rev_wet_label') - _create_slider(r_body, "Damping", self.reverb_damping, 0, 1, 100, None) - _create_slider(r_body, "Width", self.reverb_width, 0, 1, 100, None) - - # Delay - d_head = ctk.CTkFrame(sp_frame, fg_color="transparent") - d_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(d_head, text="Delay", variable=self.delay_enabled, font=("Roboto", 12, "bold")).pack(side="left") - - d_body = ctk.CTkFrame(sp_frame) - d_body.pack(fill="x", padx=10, pady=2) - _create_slider(d_body, "Time (s)", self.delay_time, 0, 2, 100, 'dly_time_label') - _create_slider(d_body, "Feedback", self.delay_feedback, 0, 1, 100, None) - _create_slider(d_body, "Mix", self.delay_mix, 0, 1, 100, 'dly_mix_label') - - # --- 4. Guitar / Modulation --- - mod_frame = ctk.CTkFrame(scroll) - mod_frame.pack(fill="x", padx=5, pady=5) - ctk.CTkLabel(mod_frame, text="Guitar / Modulation", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - # Chorus - ch_head = ctk.CTkFrame(mod_frame, fg_color="transparent") - ch_head.pack(fill="x", padx=5) - ctk.CTkCheckBox(ch_head, text="Chorus", variable=self.chorus_enabled).pack(side="left") - _create_slider(mod_frame, "Rate (Hz)", self.chorus_rate, 0.1, 10, 50, 'chorus_rate_label') - _create_slider(mod_frame, "Depth", self.chorus_depth, 0, 1, 50, None) - - # Distortion - di_head = ctk.CTkFrame(mod_frame, fg_color="transparent") - di_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(di_head, text="Distortion", variable=self.distortion_enabled).pack(side="left") - _create_slider(mod_frame, "Drive (dB)", self.distortion_drive, 0, 60, 60, 'dist_drive_label') - - # Phaser - ph_head = ctk.CTkFrame(mod_frame, fg_color="transparent") - ph_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(ph_head, text="Phaser", variable=self.phaser_enabled).pack(side="left") - _create_slider(mod_frame, "Rate (Hz)", self.phaser_rate, 0.1, 10, 50, 'phaser_rate_label') - - # Clipping - cl_head = ctk.CTkFrame(mod_frame, fg_color="transparent") - cl_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(cl_head, text="Clipping", variable=self.clipping_enabled).pack(side="left") - _create_slider(mod_frame, "Threshold (dB)", self.clipping_thresh, -20, 0, 40, 'clip_thresh_label') - - # --- 5. Quality & Pitch --- - q_frame = ctk.CTkFrame(scroll) - q_frame.pack(fill="x", padx=5, pady=5) - ctk.CTkLabel(q_frame, text="Quality / Pitch", font=("Roboto", 14, "bold")).pack(anchor="w", padx=10, pady=5) - - # Pitch Shift - ps_head = ctk.CTkFrame(q_frame, fg_color="transparent") - ps_head.pack(fill="x", padx=5) - ctk.CTkCheckBox(ps_head, text="Pitch Shift (High Quality)", variable=self.pitch_shift_enabled).pack(side="left") - _create_slider(q_frame, "Semitones", self.pitch_shift_semitones, -12, 12, 48, 'pitch_shift_label') - - # Bitcrush - bc_head = ctk.CTkFrame(q_frame, fg_color="transparent") - bc_head.pack(fill="x", padx=5, pady=(5,0)) - ctk.CTkCheckBox(bc_head, text="Bitcrush", variable=self.bitcrush_enabled).pack(side="left") - _create_slider(q_frame, "Bit Depth", self.bitcrush_depth, 2, 16, 28, 'bit_depth_label') - - # GSM - ctk.CTkCheckBox(q_frame, text="GSM Compressor (Phone Quality)", variable=self.gsm_enabled).pack(anchor="w", padx=10, pady=5) - - # Init labels - self.update_fx_labels() - self.refresh_fx_presets() - - def build_generation_tab(self, parent): - parent.grid_columnconfigure(0, weight=1) - - # Move existing logic here - main_frame = ctk.CTkScrollableFrame(parent) - main_frame.pack(fill="both", expand=True, padx=5, pady=5) - main_frame.grid_columnconfigure(0, weight=1) - - # --- 1. Input Section --- - input_frame = ctk.CTkFrame(main_frame) - input_frame.grid(row=0, column=0, sticky="ew", pady=(0, 10)) - input_frame.grid_columnconfigure(0, weight=1) - - ctk.CTkLabel(input_frame, text="Input Source", font=("Roboto", 16, "bold")).grid(row=0, column=0, sticky="w", padx=10, pady=5) - - self.tab_view = ctk.CTkTabview(input_frame, height=150) - self.tab_view.grid(row=1, column=0, sticky="ew", padx=10, pady=5) - - # Text Tab - tab_text = self.tab_view.add("Direct Text") - tab_text.grid_columnconfigure(0, weight=1) - tab_text.grid_rowconfigure(0, weight=1) - - self.text_entry = ctk.CTkTextbox(tab_text, wrap="word") - self.text_entry.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) - - # File Tab - tab_file = self.tab_view.add("Load File") - tab_file.grid_columnconfigure(1, weight=1) - - ctk.CTkLabel(tab_file, text="File Path:").grid(row=0, column=0, padx=10, pady=20) - ctk.CTkEntry(tab_file, textvariable=self.file_path_var).grid(row=0, column=1, sticky="ew", padx=5) - ctk.CTkButton(tab_file, text="Browse", width=80, command=self.browse_file).grid(row=0, column=2, padx=10) - ctk.CTkLabel(tab_file, text="Supported: .txt, .pdf, .epub", text_color="gray").grid(row=1, column=1, sticky="w", padx=5) - - # --- 2. Configuration --- - config_frame = ctk.CTkFrame(main_frame) - config_frame.grid(row=1, column=0, sticky="ew", pady=10) - config_frame.grid_columnconfigure(1, weight=1) - - ctk.CTkLabel(config_frame, text="Configuration", font=("Roboto", 16, "bold")).grid(row=0, column=0, columnspan=2, sticky="w", padx=10, pady=5) - - # Presets Row - preset_frame = ctk.CTkFrame(config_frame, fg_color="transparent") - preset_frame.grid(row=0, column=1, sticky="ew", padx=10, pady=5) - - self.preset_combo = ctk.CTkComboBox(preset_frame, values=["Select Preset..."], command=self.load_preset, width=150) - self.preset_combo.pack(side="left", padx=(0,5)) - - ctk.CTkButton(preset_frame, text="💾", width=30, command=self.save_preset_dialog).pack(side="left", padx=2) - ctk.CTkButton(preset_frame, text="🔄", width=30, command=self.refresh_presets).pack(side="left", padx=2) - - self.refresh_presets() - - # Language Selection - ctk.CTkLabel(config_frame, text="Language:").grid(row=1, column=0, sticky="w", padx=10, pady=5) - # Reverse map for display - lang_display_map = {v: k for k, v in self.LANGUAGES.items()} - current_lang_code = self.lang_var.get() - - def on_lang_ui_change(choice): - self.lang_var.set(self.LANGUAGES[choice]) - - self.lang_combo = ctk.CTkComboBox(config_frame, values=list(self.LANGUAGES.keys()), command=on_lang_ui_change) - - # Set initial value - if current_lang_code in lang_display_map: - self.lang_combo.set(lang_display_map[current_lang_code]) - else: - self.lang_combo.set("American English") - - self.lang_combo.grid(row=1, column=1, sticky="ew", padx=10) - - # Voice Selection - ctk.CTkLabel(config_frame, text="Voice:").grid(row=2, column=0, sticky="w", padx=10, pady=5) - self.voice_combo = ctk.CTkComboBox(config_frame, values=self.get_all_voices(), variable=self.voice_var) - self.voice_combo.grid(row=2, column=1, sticky="ew", padx=10) - - # Output Dir - ctk.CTkLabel(config_frame, text="Output Folder:").grid(row=3, column=0, sticky="w", padx=10, pady=5) - dir_row = ctk.CTkFrame(config_frame, fg_color="transparent") - dir_row.grid(row=3, column=1, sticky="ew", padx=10) - dir_row.grid_columnconfigure(0, weight=1) - ctk.CTkEntry(dir_row, textvariable=self.output_dir_var).grid(row=0, column=0, sticky="ew", padx=(0,5)) - ctk.CTkButton(dir_row, text="...", width=40, command=self.browse_directory).grid(row=0, column=1) - - # Filename - ctk.CTkLabel(config_frame, text="Base Filename:").grid(row=4, column=0, sticky="w", padx=10, pady=5) - - file_row = ctk.CTkFrame(config_frame, fg_color="transparent") - file_row.grid(row=4, column=1, sticky="ew", padx=10) - file_row.grid_columnconfigure(0, weight=1) - - ctk.CTkEntry(file_row, textvariable=self.filename_var).grid(row=0, column=0, sticky="ew", padx=(0,5)) - - self.format_combo = ctk.CTkComboBox(file_row, values=["wav", "flac", "mp3", "ogg"], width=70, variable=self.output_format_var) - self.format_combo.grid(row=0, column=1) - - # Speed - self.speed_label = ctk.CTkLabel(config_frame, text="Speed: 1.0x") - self.speed_label.grid(row=5, column=0, sticky="w", padx=10, pady=5) - self.speed_slider = ctk.CTkSlider(config_frame, from_=0.5, to=2.0, number_of_steps=15, variable=self.speed_var, command=self.update_speed_label) - self.speed_slider.grid(row=5, column=1, sticky="ew", padx=10) - - # Split Pattern - ctk.CTkLabel(config_frame, text="Split By:").grid(row=6, column=0, sticky="w", padx=10, pady=5) - self.split_map = { - "Natural (Newlines)": r"\n+", - "Paragraphs (Double Newline)": r"\n\n+", - "Sentences (.!?)": r"(?", width=30).pack(side="left") - ctk.CTkLabel(row, text=rep, width=150, anchor="w", font=("Consolas", 12)).pack(side="left", padx=10) - - ctk.CTkButton(row, text="X", width=30, fg_color="#c42b1c", command=lambda k=orig: self.delete_lexicon_rule(k)).pack(side="right", padx=5) - - def create_widgets(self): - # Header - self.grid_columnconfigure(0, weight=1) - self.grid_rowconfigure(1, weight=1) - self.grid_rowconfigure(2, weight=0) - - header_frame = ctk.CTkFrame(self, fg_color="transparent") - header_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=(10,0)) - - ctk.CTkLabel(header_frame, text="Kokoro TTS", font=("Roboto", 20, "bold")).pack(side="left", padx=5) - ctk.CTkButton(header_frame, text="⚙ Settings", width=80, height=28, command=self.open_settings).pack(side="right") - - # Main Tabs - self.main_tabs = ctk.CTkTabview(self) - self.main_tabs.grid(row=1, column=0, sticky="nsew", padx=10, pady=10) - - gen_tab = self.main_tabs.add("Generate Audio") - self.build_generation_tab(gen_tab) - - mix_tab = self.main_tabs.add("Custom Voice") - self.build_mixing_tab(mix_tab) - - fx_tab = self.main_tabs.add("Audio FX") - self.build_fx_tab(fx_tab) - - lex_tab = self.main_tabs.add("Lexicon") - self.build_lexicon_tab(lex_tab) - - # Actions (Global) - action_frame = ctk.CTkFrame(self) - action_frame.grid(row=2, column=0, sticky="ew", padx=10, pady=10) - - self.status_label = ctk.CTkLabel(action_frame, text="Ready", text_color="gray", anchor="w") - self.status_label.pack(fill="x", padx=10, pady=(5,0)) - - self.detail_label = ctk.CTkLabel(action_frame, text="...", font=("Consolas", 10), text_color="gray", anchor="w") - self.detail_label.pack(fill="x", padx=10, pady=(0,5)) - - self.progress_bar = ctk.CTkProgressBar(action_frame) - self.progress_bar.set(0) - self.progress_bar.pack(fill="x", padx=10, pady=5) - - self.info_label = ctk.CTkLabel(action_frame, text="Time: 00:00 / ETA: --:-- | 0%") - self.info_label.pack(pady=2) - - btn_frame = ctk.CTkFrame(action_frame, fg_color="transparent") - btn_frame.pack(fill="x", pady=10) - - self.preview_btn = ctk.CTkButton(btn_frame, text="Preview Audio", command=self.preview_conversion, height=40, fg_color="#2B719E", hover_color="#205578") - self.preview_btn.pack(side="left", fill="x", expand=True, padx=5) - - btn_txt = "Start Real-time JIT" if self.jit_enabled.get() else "Start Generation" - self.start_btn = ctk.CTkButton(btn_frame, text=btn_txt, command=self.start_conversion, height=40, font=("Roboto", 14, "bold")) - self.start_btn.pack(side="left", fill="x", expand=True, padx=5) - - self.cancel_btn = ctk.CTkButton(btn_frame, text="Cancel", command=self.cancel_conversion, height=40, fg_color="#c42b1c", hover_color="#8a1f14", state="disabled") - self.cancel_btn.pack(side="left", fill="x", expand=True, padx=5) - - def open_settings(self): - toplevel = ctk.CTkToplevel(self) - toplevel.title("Settings") - toplevel.geometry("400x380") - toplevel.grab_set() # Modal - - # Center the window - toplevel.update_idletasks() - x = self.winfo_x() + (self.winfo_width() // 2) - (toplevel.winfo_width() // 2) - y = self.winfo_y() + (self.winfo_height() // 2) - (toplevel.winfo_height() // 2) - toplevel.geometry(f"400x380+{x}+{y}") - - frame = ctk.CTkFrame(toplevel) - frame.pack(fill="both", expand=True, padx=20, pady=20) - - # Appearance - ctk.CTkLabel(frame, text="Appearance Mode:", font=("Roboto", 14, "bold")).pack(anchor="w", pady=(10, 5)) - app_menu = ctk.CTkOptionMenu(frame, values=["System", "Dark", "Light"], command=self.change_appearance) - app_menu.set(self.settings["appearance"]) - app_menu.pack(fill="x", pady=5) - - # Scaling - ctk.CTkLabel(frame, text="UI Scaling:", font=("Roboto", 14, "bold")).pack(anchor="w", pady=(15, 5)) - scale_menu = ctk.CTkOptionMenu(frame, values=["80%", "90%", "100%", "110%", "120%"], command=self.change_scaling) - scale_menu.set(self.settings["scaling"]) - scale_menu.pack(fill="x", pady=5) - - # Caching - ctk.CTkLabel(frame, text="Generation Cache:", font=("Roboto", 14, "bold")).pack(anchor="w", pady=(15, 5)) - ctk.CTkCheckBox(frame, text="Enable Generation Caching", variable=self.caching_enabled).pack(anchor="w", pady=5) - - # JIT - ctk.CTkLabel(frame, text="Real-time / JIT:", font=("Roboto", 14, "bold")).pack(anchor="w", pady=(15, 5)) - ctk.CTkCheckBox(frame, text="Enable JIT Generation (Streaming)", variable=self.jit_enabled, command=self.on_jit_toggle).pack(anchor="w", pady=5) - - ctk.CTkLabel(frame, text="Note: Restart may be required for optimal scaling.", text_color="gray", font=("Arial", 10)).pack(pady=20) - - ctk.CTkButton(frame, text="Close", command=toplevel.destroy).pack(side="bottom", pady=10) - - def change_appearance(self, new_val): - self.settings["appearance"] = new_val - ctk.set_appearance_mode(new_val) - self.save_settings() - - def change_scaling(self, new_val): - self.settings["scaling"] = new_val - scale_float = float(new_val.replace("%", "")) / 100 - ctk.set_widget_scaling(scale_float) - self.save_settings() - - def on_jit_toggle(self): - if self.jit_enabled.get(): - self.start_btn.configure(text="Start Real-time JIT") - else: - self.start_btn.configure(text="Start Generation") - self.save_settings() - - # --- Logic --- - - def update_audio_labels(self, value): - self.vol_label.configure(text=f"Volume: {int(self.volume_var.get() * 100)}%") - self.pitch_label.configure(text=f"Pitch: {int(self.pitch_var.get())} st") - - def update_speed_label(self, value): - self.speed_label.configure(text=f"Speed: {value:.1f}x") - - def update_fx_labels(self): - # EQ - if hasattr(self, 'bass_label'): self.bass_label.configure(text=f"Bass: {self.eq_bass.get():.1f} dB") - if hasattr(self, 'treble_label'): self.treble_label.configure(text=f"Treble: {self.eq_treble.get():.1f} dB") - if hasattr(self, 'hpf_label'): self.hpf_label.configure(text=f"Freq: {int(self.highpass_freq.get())} Hz") - if hasattr(self, 'lpf_label'): self.lpf_label.configure(text=f"Freq: {int(self.lowpass_freq.get())} Hz") - - # Comp / Dynamics - if hasattr(self, 'comp_thresh_label'): self.comp_thresh_label.configure(text=f"Thresh: {self.comp_threshold.get():.1f} dB") - if hasattr(self, 'comp_ratio_label'): self.comp_ratio_label.configure(text=f"Ratio: {self.comp_ratio.get():.1f}:1") - if hasattr(self, 'lim_thresh_label'): self.lim_thresh_label.configure(text=f"Thresh: {self.limiter_threshold.get():.1f} dB") - if hasattr(self, 'gain_label'): self.gain_label.configure(text=f"Gain: {self.gain_db.get():.1f} dB") - - # Reverb - if hasattr(self, 'rev_room_label'): self.rev_room_label.configure(text=f"Size: {self.reverb_room_size.get():.2f}") - if hasattr(self, 'rev_wet_label'): self.rev_wet_label.configure(text=f"Wet: {self.reverb_wet_level.get():.2f}") - - # Delay - if hasattr(self, 'dly_time_label'): self.dly_time_label.configure(text=f"Time: {self.delay_time.get():.2f} s") - if hasattr(self, 'dly_mix_label'): self.dly_mix_label.configure(text=f"Mix: {self.delay_mix.get():.2f}") - - # Guitar - if hasattr(self, 'dist_drive_label'): self.dist_drive_label.configure(text=f"Drive: {self.distortion_drive.get():.1f} dB") - if hasattr(self, 'chorus_rate_label'): self.chorus_rate_label.configure(text=f"Rate: {self.chorus_rate.get():.1f} Hz") - if hasattr(self, 'phaser_rate_label'): self.phaser_rate_label.configure(text=f"Rate: {self.phaser_rate.get():.1f} Hz") - if hasattr(self, 'clip_thresh_label'): self.clip_thresh_label.configure(text=f"Thresh: {self.clipping_thresh.get():.1f} dB") - - # Quality / Pitch - if hasattr(self, 'bit_depth_label'): self.bit_depth_label.configure(text=f"Depth: {self.bitcrush_depth.get():.1f}") - if hasattr(self, 'pitch_shift_label'): self.pitch_shift_label.configure(text=f"Shift: {self.pitch_shift_semitones.get():.1f} st") - - def change_threads(self, delta): - try: - current = int(self.num_threads_var.get()) - except Exception: - current = 1 - new_val = max(1, min(16, current + delta)) - self.num_threads_var.set(new_val) - - def update_split_pattern(self, choice): - self.split_pattern_var.set(self.split_map[choice]) - - def browse_directory(self): - d = filedialog.askdirectory() - if d: self.output_dir_var.set(d) - - def browse_file(self): - f = filedialog.askopenfilename(filetypes=[("Documents", "*.txt *.pdf *.epub")]) - if f: self.file_path_var.set(f) - - def on_engine_status(self, msg, is_error): - color = "#ff5555" if is_error else "gray" # Red or Gray - # Schedule update on main thread - self.after(0, lambda: self.status_label.configure(text=msg.split('\n')[0], text_color=color)) - - if is_error and "pip install" in msg: - self.after(0, lambda: messagebox.showerror("Missing Dependencies", msg)) - - def on_engine_progress(self, percent, elapsed, eta, detail): - # Schedule update - def _update(): - self.progress_bar.set(percent / 100.0) - elapsed_str = time.strftime('%M:%S', time.gmtime(elapsed)) - self.info_label.configure(text=f"Time: {elapsed_str} / ETA: {eta} | {int(percent)}%") - self.detail_label.configure(text=detail) - self.after(0, _update) - - def on_engine_finish(self): - self.after(0, lambda: self.set_ui_state(False)) - - def set_ui_state(self, is_running): - state = "disabled" if is_running else "normal" - cancel_state = "normal" if is_running else "disabled" - - self.start_btn.configure(state=state) - self.preview_btn.configure(state=state) - self.cancel_btn.configure(state=cancel_state) - self.thread_minus_btn.configure(state=state) - self.thread_plus_btn.configure(state=state) - self.thread_entry.configure(state=state) - self.vol_slider.configure(state=state) - self.pitch_slider.configure(state=state) - - if not is_running: - self.progress_bar.set(0 if self.engine.cancel_event.is_set() else 1) - - def preview_conversion(self): - if not self.engine.pipeline: - messagebox.showinfo("Wait", "Engine is initializing... please wait 2 seconds and try again.") - return - - # 1. Get Text - current_tab = self.tab_view.get() - text_data = "" - - if current_tab == "Direct Text": - text_data = self.text_entry.get("1.0", "end").strip() - else: - fpath = self.file_path_var.get() - if os.path.exists(fpath): - try: - text_data = self.engine.extract_text_from_file(fpath) - except Exception: - pass - - if not text_data: - text_data = "This is a sample audio preview using the Koh-koh-ro Tea-Tea-S engine. It demonstrates the voice quality and speed settings." - - preview_text = text_data - if len(preview_text) > 1000: # Slightly larger cap for raw text before engine handles it - preview_text = preview_text[:1000] - - # Config - voice = self.voice_var.get() - speed = self.speed_var.get() - - extra_config = { - 'volume': self.volume_var.get(), - 'pitch': self.pitch_var.get(), - 'normalize': self.normalize_audio.get(), - 'trim_silence': self.trim_silence.get(), - 'lexicon': self.settings.get('lexicon', {}) - } - - if self.apply_fx_var.get(): - extra_config.update({ - 'reverb_enabled': self.reverb_enabled.get(), - 'reverb_room_size': self.reverb_room_size.get(), - 'reverb_wet_level': self.reverb_wet_level.get(), - 'reverb_damping': self.reverb_damping.get(), - 'reverb_dry_level': self.reverb_dry_level.get(), - 'reverb_width': self.reverb_width.get(), - 'eq_bass': self.eq_bass.get(), - 'eq_treble': self.eq_treble.get(), - 'comp_enabled': self.comp_enabled.get(), - 'comp_threshold': self.comp_threshold.get(), - 'comp_ratio': self.comp_ratio.get(), - 'comp_attack': self.comp_attack.get(), - 'comp_release': self.comp_release.get(), - 'distortion_enabled': self.distortion_enabled.get(), - 'distortion_drive': self.distortion_drive.get(), - 'chorus_enabled': self.chorus_enabled.get(), - 'chorus_rate': self.chorus_rate.get(), - 'chorus_depth': self.chorus_depth.get(), - 'chorus_mix': self.chorus_mix.get(), - 'phaser_enabled': self.phaser_enabled.get(), - 'phaser_rate': self.phaser_rate.get(), - 'phaser_depth': self.phaser_depth.get(), - 'phaser_mix': self.phaser_mix.get(), - 'clipping_enabled': self.clipping_enabled.get(), - 'clipping_thresh': self.clipping_thresh.get(), - 'bitcrush_enabled': self.bitcrush_enabled.get(), - 'bitcrush_depth': self.bitcrush_depth.get(), - 'gsm_enabled': self.gsm_enabled.get(), - 'highpass_enabled': self.highpass_enabled.get(), - 'highpass_freq': self.highpass_freq.get(), - 'lowpass_enabled': self.lowpass_enabled.get(), - 'lowpass_freq': self.lowpass_freq.get(), - 'delay_enabled': self.delay_enabled.get(), - 'delay_time': self.delay_time.get(), - 'delay_feedback': self.delay_feedback.get(), - 'delay_mix': self.delay_mix.get(), - 'pitch_shift_enabled': self.pitch_shift_enabled.get(), - 'pitch_shift_semitones': self.pitch_shift_semitones.get(), - 'limiter_enabled': self.limiter_enabled.get(), - 'limiter_threshold': self.limiter_threshold.get(), - 'limiter_release': self.limiter_release.get(), - 'gain_enabled': self.gain_enabled.get(), - 'gain_db': self.gain_db.get() - }) - - # Temp file - import tempfile - tmp_path = os.path.join(tempfile.gettempdir(), "kokoro_preview.wav") - - self.status_label.configure(text="Generating preview...", text_color="blue") - - def _on_preview_done(future): - def _ui_update(): - try: - success = future.result() - if success: - self.status_label.configure(text="Playing preview...", text_color="green") - playback.play(tmp_path) - self.after(3000, lambda: self.status_label.configure(text="Ready", text_color="gray")) - else: - self.status_label.configure(text="Preview failed.", text_color="red") - except Exception as e: - self.status_label.configure(text=f"Preview error: {e}", text_color="red") - - self.after(0, _ui_update) - - future = self.engine.worker.run_coro(self.engine.generate_preview(preview_text, voice, speed, tmp_path, extra_config, lang_code=self.lang_var.get())) - future.add_done_callback(_on_preview_done) - - def start_conversion(self): - # 0. Validate Threads - try: - val = int(self.num_threads_var.get()) - if val < 1: val = 1 - self.num_threads_var.set(val) - except Exception: - self.num_threads_var.set(1) - - # 1. Get Text - current_tab = self.tab_view.get() - text_data = "" - - if current_tab == "Direct Text": - text_data = self.text_entry.get("1.0", "end").strip() - else: - fpath = self.file_path_var.get() - if not os.path.exists(fpath): - messagebox.showerror("Error", "File not found.") - return - try: - text_data = self.engine.extract_text_from_file(fpath) - except Exception as e: - messagebox.showerror("Error", f"Read failed: {e}") - return - - if not text_data: - messagebox.showwarning("Empty", "No text to process.") - return - - if not self.engine.pipeline: - messagebox.showinfo("Wait", "Engine is initializing... please wait 2 seconds and try again.") - return - - # 2. Config - config = { - 'lang_code': self.lang_var.get(), - 'voice': self.voice_var.get(), - 'speed': self.speed_var.get(), - 'split_pattern': self.split_pattern_var.get(), - 'filename': self.filename_var.get(), - 'format': self.output_format_var.get(), - 'out_dir': self.output_dir_var.get(), - 'separate': self.separate_files.get(), - 'combine': self.combine_post.get(), - 'export_subtitles': self.export_subtitles.get(), - 'caching': self.caching_enabled.get(), - 'time_id': time.strftime(self.timecode_format), - 'num_threads': self.num_threads_var.get(), - 'volume': self.volume_var.get(), - 'pitch': self.pitch_var.get(), - 'normalize': self.normalize_audio.get(), - 'trim_silence': self.trim_silence.get(), - 'lexicon': self.settings.get('lexicon', {}) - } - - if self.apply_fx_var.get(): - config.update({ - 'reverb_enabled': self.reverb_enabled.get(), - 'reverb_room_size': self.reverb_room_size.get(), - 'reverb_wet_level': self.reverb_wet_level.get(), - 'reverb_damping': self.reverb_damping.get(), - 'reverb_dry_level': self.reverb_dry_level.get(), - 'reverb_width': self.reverb_width.get(), - 'eq_bass': self.eq_bass.get(), - 'eq_treble': self.eq_treble.get(), - 'comp_enabled': self.comp_enabled.get(), - 'comp_threshold': self.comp_threshold.get(), - 'comp_ratio': self.comp_ratio.get(), - 'comp_attack': self.comp_attack.get(), - 'comp_release': self.comp_release.get(), - 'distortion_enabled': self.distortion_enabled.get(), - 'distortion_drive': self.distortion_drive.get(), - 'chorus_enabled': self.chorus_enabled.get(), - 'chorus_rate': self.chorus_rate.get(), - 'chorus_depth': self.chorus_depth.get(), - 'chorus_mix': self.chorus_mix.get(), - 'phaser_enabled': self.phaser_enabled.get(), - 'phaser_rate': self.phaser_rate.get(), - 'phaser_depth': self.phaser_depth.get(), - 'phaser_mix': self.phaser_mix.get(), - 'clipping_enabled': self.clipping_enabled.get(), - 'clipping_thresh': self.clipping_thresh.get(), - 'bitcrush_enabled': self.bitcrush_enabled.get(), - 'bitcrush_depth': self.bitcrush_depth.get(), - 'gsm_enabled': self.gsm_enabled.get(), - 'highpass_enabled': self.highpass_enabled.get(), - 'highpass_freq': self.highpass_freq.get(), - 'lowpass_enabled': self.lowpass_enabled.get(), - 'lowpass_freq': self.lowpass_freq.get(), - 'delay_enabled': self.delay_enabled.get(), - 'delay_time': self.delay_time.get(), - 'delay_feedback': self.delay_feedback.get(), - 'delay_mix': self.delay_mix.get(), - 'pitch_shift_enabled': self.pitch_shift_enabled.get(), - 'pitch_shift_semitones': self.pitch_shift_semitones.get(), - 'limiter_enabled': self.limiter_enabled.get(), - 'limiter_threshold': self.limiter_threshold.get(), - 'limiter_release': self.limiter_release.get(), - 'gain_enabled': self.gain_enabled.get(), - 'gain_db': self.gain_db.get() - }) - - # 3. Start - self.set_ui_state(True) - self.progress_bar.set(0) - - if self.jit_enabled.get(): - self.engine.start_jit_conversion(text_data, config) - else: - self.engine.start_conversion(text_data, config) - - def cancel_conversion(self): - self.engine.cancel() - self.status_label.configure(text="Cancelling... waiting for workers...", text_color="orange") - - def on_close(self): - self.save_settings() - self.destroy() - -if __name__ == "__main__": - app = TTSApp() - app.mainloop() diff --git a/kokoro_engine.py b/kokoro_engine.py index cd3764d..b27c772 100644 --- a/kokoro_engine.py +++ b/kokoro_engine.py @@ -2,46 +2,45 @@ import threading import asyncio import time -import concurrent.futures -import soundfile as sf -import torch -import numpy as np -import scipy.signal -import hashlib -from pedalboard import ( - Pedalboard, Reverb, Compressor, HighShelfFilter, LowShelfFilter, - Chorus, Distortion, Phaser, Clipping, Gain, Limiter, - HighpassFilter, LowpassFilter, LadderFilter, Delay, PitchShift, - GSMFullRateCompressor, Bitcrush -) -from pedalboard.io import AudioFile import pypdf import ebooklib from ebooklib import epub -from bs4 import BeautifulSoup import warnings -import re -import json import playback -import tempfile from kokoro import KPipeline +from kokoro_gui.engine import ( + AudioFXMixin, CachingMixin, ConversionMixin, JITMixin, LexiconMixin, + PresetsMixin, SrtMixin, TextExtractionMixin, VoiceMixingMixin, +) + # Suppress ebooklib warnings warnings.filterwarnings("ignore", category=UserWarning, module='ebooklib') warnings.filterwarnings("ignore", category=FutureWarning, module='ebooklib') CUSTOM_VOICES_DIR = "custom_voices" CACHE_DIR = "cache" +STATS_FILE = "generation_stats.json" # per-engine generation-history, see kokoro_gui/engine/stats.py # --- Thread Local Storage --- thread_local = threading.local() +# Options > Device in the Qt shell writes settings["device"] ("auto" | "cpu" | +# "cuda"); init_pipeline_async() copies it here so every worker thread's +# KPipeline lands on the same device. None means "let kokoro pick". +PIPELINE_DEVICE = None + + +def _pipeline_kwargs(): + return {"device": PIPELINE_DEVICE} if PIPELINE_DEVICE else {} + + def get_thread_pipeline(lang_code="a"): """Get or create a KPipeline instance for the current thread.""" current = getattr(thread_local, "pipeline", None) if current is None or getattr(current, "lang_code", None) != lang_code: try: - thread_local.pipeline = KPipeline(lang_code=lang_code) + thread_local.pipeline = KPipeline(lang_code=lang_code, **_pipeline_kwargs()) except Exception as e: print(f"Error init pipeline in thread {threading.get_ident()}: {e}") return None @@ -64,19 +63,22 @@ def stop(self): def run_coro(self, coro): return asyncio.run_coroutine_threadsafe(coro, self.loop) -class KokoroEngine: +class KokoroEngine( + AudioFXMixin, CachingMixin, ConversionMixin, JITMixin, LexiconMixin, + PresetsMixin, SrtMixin, TextExtractionMixin, VoiceMixingMixin, +): def __init__(self): self.worker = AsyncLoopThread() self.worker.start() self.cancel_event = threading.Event() self.pipeline = None # Main pipeline for single thread check or init - + if not os.path.exists(CUSTOM_VOICES_DIR): os.makedirs(CUSTOM_VOICES_DIR) - + if not os.path.exists(CACHE_DIR): os.makedirs(CACHE_DIR) - + # Callbacks self.on_progress = None # func(percentage, time_elapsed, eta, detail_text) self.on_status = None # func(msg, is_error) @@ -84,186 +86,41 @@ def __init__(self): self._lexicon_cache = {} # Cache for compiled regexes - def apply_lexicon(self, text, lexicon): - """ - Applies a dictionary of replacements to the text. - Case-insensitive finding, preserves case of replacement. - """ - if not lexicon: - return text - - for src, dest in lexicon.items(): - if not src: continue - try: - # Use cached pattern if available to avoid repeated recompilation overhead - if src not in self._lexicon_cache: - # Escape the search term to treat it as literal text - self._lexicon_cache[src] = re.compile(re.escape(src), re.IGNORECASE) - - pattern = self._lexicon_cache[src] - text = pattern.sub(dest, text) - except Exception as e: - print(f"Lexicon error for '{src}': {e}") - - return text - - def resolve_voice_path(self, voice_name): - """ - Returns the absolute path if it's a custom voice, - otherwise returns the name as-is (for standard voices). - """ - # Sanitize voice_name to prevent path traversal - safe_voice_name = os.path.basename(voice_name) - # Check if it's a custom voice file - custom_path = os.path.join(CUSTOM_VOICES_DIR, f"{safe_voice_name}.pt") - if os.path.exists(custom_path): - return os.path.abspath(custom_path) - return voice_name - - def process_audio(self, audio, sr, config): - """ - Apply post-processing: Pitch (Resample), Volume, FX (Reverb, EQ, Comp), Normalize, Trim. - Returns: (processed_audio, new_sr) - """ - # 1. Trim Silence (Simple threshold) - if config.get('trim_silence', False): - threshold = 0.01 - # Find first index > threshold - mask = np.abs(audio) > threshold - if np.any(mask): - start = np.argmax(mask) - end = len(audio) - np.argmax(mask[::-1]) - audio = audio[start:end] - - # 2. Volume / Gain - vol = config.get('volume', 1.0) - if vol != 1.0: - audio = audio * vol - - # 3. Pitch Shift (Resampling) - pitch_semitones = config.get('pitch', 0.0) - if pitch_semitones != 0.0: - factor = 2 ** (pitch_semitones / 12.0) - new_len = int(len(audio) / factor) - if new_len > 0: - try: - audio = scipy.signal.resample(audio, new_len) - except Exception as e: - print(f"Resample failed: {e}") - - # 4. Pedalboard FX - fx_chain = [] - - if config.get('apply_fx', True): - # --- Guitar / Modulation --- - if config.get('distortion_enabled', False): - drive = config.get('distortion_drive', 25.0) - fx_chain.append(Distortion(drive_db=drive)) - - if config.get('chorus_enabled', False): - fx_chain.append(Chorus( - rate_hz=config.get('chorus_rate', 1.0), - depth=config.get('chorus_depth', 0.25), - mix=config.get('chorus_mix', 0.5) - )) - - if config.get('phaser_enabled', False): - fx_chain.append(Phaser( - rate_hz=config.get('phaser_rate', 1.0), - depth=config.get('phaser_depth', 0.5), - mix=config.get('phaser_mix', 0.5) - )) - - if config.get('clipping_enabled', False): - fx_chain.append(Clipping(threshold_db=config.get('clipping_thresh', -6.0))) - - if config.get('bitcrush_enabled', False): - fx_chain.append(Bitcrush(bit_depth=config.get('bitcrush_depth', 8.0))) - - if config.get('gsm_enabled', False): - fx_chain.append(GSMFullRateCompressor()) - - # --- Filters / EQ --- - # HighPass - if config.get('highpass_enabled', False): - fx_chain.append(HighpassFilter(cutoff_frequency_hz=config.get('highpass_freq', 50.0))) - - # LowPass - if config.get('lowpass_enabled', False): - fx_chain.append(LowpassFilter(cutoff_frequency_hz=config.get('lowpass_freq', 10000.0))) - - # Shelves (Bass/Treble) - Simple EQ - bass_db = config.get('eq_bass', 0.0) - if bass_db != 0.0: - fx_chain.append(LowShelfFilter(cutoff_frequency_hz=250, gain_db=bass_db)) - - treble_db = config.get('eq_treble', 0.0) - if treble_db != 0.0: - fx_chain.append(HighShelfFilter(cutoff_frequency_hz=4000, gain_db=treble_db)) - - # --- Spatial / Time --- - if config.get('pitch_shift_enabled', False): - # High quality pitch shifting without duration change - semitones = config.get('pitch_shift_semitones', 0.0) - if semitones != 0: - fx_chain.append(PitchShift(semitones=semitones)) - - if config.get('delay_enabled', False): - fx_chain.append(Delay( - delay_seconds=config.get('delay_time', 0.5), - feedback=config.get('delay_feedback', 0.0), - mix=config.get('delay_mix', 0.5) - )) - - if config.get('reverb_enabled', False): - fx_chain.append(Reverb( - room_size=config.get('reverb_room_size', 0.5), - damping=config.get('reverb_damping', 0.5), - wet_level=config.get('reverb_wet_level', 0.3), - dry_level=config.get('reverb_dry_level', 1.0), - width=config.get('reverb_width', 1.0) - )) - - # --- Dynamics --- - if config.get('comp_enabled', False): - fx_chain.append(Compressor( - threshold_db=config.get('comp_threshold', -20), - ratio=config.get('comp_ratio', 4), - attack_ms=config.get('comp_attack', 1.0), - release_ms=config.get('comp_release', 100.0) - )) - - if config.get('limiter_enabled', False): - fx_chain.append(Limiter( - threshold_db=config.get('limiter_threshold', -1.0), - release_ms=config.get('limiter_release', 100.0) - )) - - if config.get('gain_enabled', False): - db = config.get('gain_db', 0.0) - if db != 0.0: - fx_chain.append(Gain(gain_db=db)) - - if fx_chain: - try: - board = Pedalboard(fx_chain) - # Pedalboard expects float32 - audio = board(audio, sr) - except Exception as e: - print(f"Pedalboard FX failed: {e}") - - # 5. Normalization - if config.get('normalize', False): - peak = np.max(np.abs(audio)) - if peak > 0: - target_peak = 0.98 - audio = audio / peak * target_peak - - return audio - - async def init_pipeline_async(self, lang_code="a"): + def get_thread_pipeline(self, lang_code="a"): + """Instance-method indirection to the module-level thread-local + KPipeline getter, so the generic mixins (caching.py, conversion.py) + can call `self.get_thread_pipeline(...)` polymorphically instead of + hard-coding `kokoro_engine.get_thread_pipeline` - the one piece of + that shared pipeline that's genuinely Kokoro-specific (see + kokoro_gui/engines/dummy.py for a from-scratch, non-Kokoro backend + built on the same generic mixins). Calls the free function by name + (not a direct reference) so `monkeypatch.setattr(kokoro_engine, + "get_thread_pipeline", ...)` in tests still takes effect.""" + return get_thread_pipeline(lang_code) + + async def init_pipeline_async(self, lang_code="a", device=None): + global PIPELINE_DEVICE + if device is not None: + PIPELINE_DEVICE = None if device == "auto" else device try: - self.pipeline = await asyncio.to_thread(KPipeline, lang_code=lang_code) + try: + self.pipeline = await asyncio.to_thread(KPipeline, lang_code=lang_code, **_pipeline_kwargs()) + except Exception: + if lang_code == "a": + raise + # A lang_code value left over from a different engine + # backend (e.g. Audio8 stores full language names like + # "English", not Kokoro's single-letter codes) can reach + # here despite the Settings dock's own combo-fallback + # reconciliation (kokoro_gui/qt/docks/settings_dock.py's + # SchemaFormWidget._set_combo) - KPipeline itself rejects it + # with a raw AssertionError against its own internal + # LANG_CODES table. Retry once with Kokoro's own safe + # default rather than surface that to the user; if "a" + # itself fails (a real problem - missing model, no network, + # etc.), let that failure propagate normally below. + self.pipeline = await asyncio.to_thread(KPipeline, lang_code="a", **_pipeline_kwargs()) + lang_code = "a" if self.on_status: self.on_status(f"Pipeline Initialized ({lang_code}).", False) return True except Exception as e: @@ -273,463 +130,10 @@ async def init_pipeline_async(self, lang_code="a"): msg += "\n(Try: pip install fugashi unidic-lite)" elif lang_code == 'z' and "pypinyin" in err_str: msg += "\n(Try: pip install pypinyin)" - - if self.on_status: self.on_status(msg, True) - return False - - async def mix_voices(self, v1_name, v2_name, ratio, new_name, op='mix'): - def _mix(): - try: - # Ensure we have a pipeline to load voices - # Use 'a' as default for mixing if main pipeline is not ready - p = self.pipeline - if not p: - p = get_thread_pipeline('a') - if not p: raise RuntimeError("No pipeline available for mixing") - - # Resolve inputs (handle custom vs standard) - v1_arg = self.resolve_voice_path(v1_name) - v2_arg = self.resolve_voice_path(v2_name) - - # Load tensors - # KPipeline.load_voice returns a tensor - t1 = p.load_voice(v1_arg) - t2 = p.load_voice(v2_arg) - - if t1 is None or t2 is None: - raise ValueError("Failed to load one of the voices.") - - # Ensure they are on CPU for mixing - if isinstance(t1, torch.Tensor): t1 = t1.cpu() - if isinstance(t2, torch.Tensor): t2 = t2.cpu() - - # Check shapes - if t1.shape != t2.shape: - # Try to align? Usually kokoro voices are fixed size [510, 1, 256] - # If different, we might fail or warn. - print(f"Warning: Voice shapes differ {t1.shape} vs {t2.shape}. Mixing might fail or produce garbage.") - - # Apply operation - if op == 'add': - mixed = t1 + t2 * ratio - elif op == 'subtract': - mixed = t1 - t2 * ratio - elif op == 'multiply': - # Lerp between t1 and t1*t2 - mixed = t1 * (1.0 - ratio) + (t1 * t2) * ratio - elif op == 'divide': - # Lerp between t1 and t1/t2 - mixed = t1 * (1.0 - ratio) + (t1 / (t2 + 1e-6)) * ratio - else: # Default: mix (Linear Interpolation) - # mixed = v1 * (1 - ratio) + v2 * ratio - # ratio is mix of B. If ratio 0, full A. If ratio 1, full B. - mixed = t1 * (1.0 - ratio) + t2 * ratio - - # Save - # Sanitize new_name to prevent path traversal - safe_new_name = os.path.basename(new_name) - out_path = os.path.join(CUSTOM_VOICES_DIR, f"{safe_new_name}.pt") - torch.save(mixed, out_path) - return True, out_path, mixed - except Exception as e: - return False, str(e), None - - return await asyncio.to_thread(_mix) - - async def generate_preview(self, text, voice, speed, output_path, extra_config=None, voice_tensor=None, lang_code='a'): - def _gen(): - # Use specific lang code for preview - p = get_thread_pipeline(lang_code) - if not p: return False - - try: - ms_segments = self.parse_multispeaker_text(text) - # Truncate to first 2 segments for preview if many - if len(ms_segments) > 2: - ms_segments = ms_segments[:2] - - all_pieces = [] - - for speaker_name, fx_name, segment_text in ms_segments: - # Apply Lexicon if provided in extra_config - if extra_config and 'lexicon' in extra_config: - segment_text = self.apply_lexicon(segment_text, extra_config['lexicon']) - - # Truncate segment text if too long for preview - if len(segment_text) > 500: - segment_text = segment_text[:500] - - target_voice = voice - target_speed = speed - target_extra = extra_config.copy() if extra_config else {} - - if speaker_name: - preset = self.load_preset(speaker_name) - if preset: - target_voice = preset.get('voice', target_voice) - target_speed = preset.get('speed', target_speed) - if 'volume' in preset: target_extra['volume'] = preset['volume'] - if 'pitch' in preset: target_extra['pitch'] = preset['pitch'] - if 'normalize' in preset: target_extra['normalize'] = preset['normalize'] - if 'trim' in preset: target_extra['trim_silence'] = preset['trim'] - # If speaker preset has an FX preset, it can be overridden by the colon syntax - if 'fx_preset' in preset: - target_extra['fx_preset'] = preset['fx_preset'] - if 'apply_fx' in preset: - target_extra['apply_fx'] = preset['apply_fx'] - - if fx_name: - fx_preset = self.load_fx_preset(fx_name) - if fx_preset: - target_extra.update(fx_preset) - target_extra['apply_fx'] = True - target_extra['fx_preset'] = fx_name - - # Resolve voice - if voice_tensor is not None and not speaker_name: - # Only use voice_tensor if no speaker name (direct preview of mix) - actual_voice = "_preview_temp" - p.voices[actual_voice] = voice_tensor - else: - actual_voice = self.resolve_voice_path(target_voice) - - # Pitch Compensation - eff_speed = target_speed - pitch_st = target_extra.get('pitch', 0.0) - if pitch_st != 0.0: - factor = 2 ** (pitch_st / 12.0) - eff_speed = target_speed / factor - - # Generate - generator = p(segment_text, voice=actual_voice, speed=eff_speed, split_pattern=r"\n+") - for _, _, audio in generator: - if isinstance(audio, torch.Tensor): - audio = audio.cpu().numpy() - # Post Process - audio = self.process_audio(audio, 24000, target_extra) - all_pieces.append(audio) - - if not all_pieces: - return False - - full_audio = np.concatenate(all_pieces) - - try: - with AudioFile(output_path, 'w', samplerate=24000, num_channels=1) as f: - f.write(full_audio) - return True - except Exception as e: - print(f"Preview write error: {e}") - # Fallback - sf.write(output_path, full_audio, 24000) - return True - except Exception as e: - print(f"Preview error: {e}") - return False - - return await asyncio.to_thread(_gen) - - def extract_text_from_file(self, fpath): - if not os.path.exists(fpath): - raise FileNotFoundError("File does not exist.") - - text_data = "" - lower_path = fpath.lower() - - if lower_path.endswith(".pdf"): - reader = pypdf.PdfReader(fpath) - for page in reader.pages: - extracted = page.extract_text() - if extracted: - text_data += extracted + "\n\n" - - elif lower_path.endswith(".epub"): - book = epub.read_epub(fpath, options={'ignore_ncx': True}) - for item in book.get_items(): - if item.get_type() == ebooklib.ITEM_DOCUMENT: - soup = BeautifulSoup(item.get_content(), 'html.parser') - text_data += soup.get_text(separator='\n\n') + "\n\n" - else: - # Assume text based - with open(fpath, "r", encoding="utf-8") as f: - text_data = f.read() - - return text_data - - def parse_multispeaker_text(self, text): - """ - Parses text for [PresetName]: or [PresetName:FXPresetName]: syntax. - Returns a list of (speaker_name, fx_name, text_segment) - """ - # Regex to find [Name]: or [Name:FX]: - - pattern = r"\[([^\]\n]{1,100})\]:\s*" - matches = list(re.finditer(pattern, text)) - - if not matches: - return [(None, None, text)] - - segments = [] - for i in range(len(matches)): - raw_name = matches[i].group(1) - speaker_name = raw_name - fx_name = None - - if ":" in raw_name: - parts = raw_name.split(":", 1) - speaker_name = parts[0].strip() - fx_name = parts[1].strip() - - start = matches[i].end() - end = matches[i+1].start() if i+1 < len(matches) else len(text) - segment_text = text[start:end].strip() - if segment_text: - segments.append((speaker_name, fx_name, segment_text)) - - return segments - - def load_preset(self, name): - """Loads a preset from the presets directory.""" - # Sanitize name to prevent path traversal - safe_name = os.path.basename(name) - preset_path = os.path.join("presets", f"{safe_name}.json") - if os.path.exists(preset_path): - try: - with open(preset_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception as e: - print(f"Error loading preset {name}: {e}") - return None - - def load_fx_preset(self, name): - """Loads an FX preset from the presets/fx directory.""" - # Sanitize name to prevent path traversal - safe_name = os.path.basename(name) - fx_path = os.path.join("presets", "fx", f"{safe_name}.json") - if os.path.exists(fx_path): - try: - with open(fx_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception as e: - print(f"Error loading FX preset {name}: {e}") - return None - def smart_split(self, text, chunk_size=3000): - chunks = [] - current_chunk = [] - current_len = 0 - paragraphs = text.split('\n\n') - - for para in paragraphs: - if len(para) > chunk_size: - lines = para.split('\n') - for line in lines: - if current_len + len(line) > chunk_size and current_chunk: - chunks.append("\n".join(current_chunk)) - current_chunk = [] - current_len = 0 - current_chunk.append(line) - current_len += len(line) - else: - if current_len + len(para) > chunk_size and current_chunk: - chunks.append("\n\n".join(current_chunk)) - current_chunk = [] - current_len = 0 - current_chunk.append(para) - current_len += len(para) - - if current_chunk: - chunks.append("\n\n".join(current_chunk)) - return [c for c in chunks if c.strip()] - - def generate_srt(self, segments, output_path): - def format_time(seconds): - millis = int((seconds - int(seconds)) * 1000) - seconds = int(seconds) - minutes, seconds = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - return f"{hours:02}:{minutes:02}:{seconds:02},{millis:03}" - - try: - with open(output_path, "w", encoding="utf-8") as f: - current_time = 0.0 - for i, seg in enumerate(segments): - start = current_time - end = current_time + seg['duration'] - f.write(f"{i+1}\n") - f.write(f"{format_time(start)} --> {format_time(end)}\n") - f.write(f"{seg['text'].strip()}\n\n") - current_time = end - return True - except Exception as e: - print(f"Failed to generate SRT: {e}") + if self.on_status: self.on_status(msg, True) return False - def process_chunk_task(self, chunk_data, progress_callback): - index, text, config = chunk_data - if self.cancel_event.is_set(): return [] - - # Use lang_code from config, default to 'a' - lang_code = config.get('lang_code', 'a') - - # Speed Adjustment for Pitch Compensation - eff_speed = config['speed'] - pitch_semitones = config.get('pitch', 0.0) - if pitch_semitones != 0.0: - factor = 2 ** (pitch_semitones / 12.0) - eff_speed = eff_speed / factor - - # --- Caching Check (WAV only) --- - use_cache = config.get('caching', True) - cache_hash = None - cached_segments = [] - - if use_cache: - to_hash = f"{text}|{config['voice']}|{eff_speed}|{lang_code}" - cache_hash = hashlib.md5(to_hash.encode('utf-8')).hexdigest() - - # Predict segments to verify cache integrity - try: - # Mimic KPipeline splitting logic roughly to align with file indices - # Note: KPipeline might strip whitespace or handle things slightly differently. - # This is a heuristic. If file count matches segment count, we assume cache is valid. - split_pat = config.get('split_pattern', r"\n+") - predicted_texts = [t.strip() for t in re.split(split_pat, text) if t.strip()] - - if not predicted_texts: - # If text is empty/whitespace but passed here, treat as single empty? - # Usually smart_split handles this. - predicted_texts = [] - - all_exist = True - loaded_data = [] - - if predicted_texts: - for i, seg_text in enumerate(predicted_texts): - f_name = f"{cache_hash}_{i}.wav" - f_path = os.path.join(CACHE_DIR, f_name) - if not os.path.exists(f_path): - all_exist = False - break - # Load raw audio - audio_data, _ = sf.read(f_path) - loaded_data.append((seg_text, '', audio_data)) # phonemes empty - - # Ensure no extra files (e.g. from a previous run with same hash but more splits?) - # Hash includes text, so split count shouldn't change unless split_pattern changes. - # If split_pattern changes, hash logic might not capture it unless we add pattern to hash. - # Ideally we should add split_pattern to hash, but current requirement is simpler. - # For now, if we found all expected parts, we accept it. - else: - all_exist = False # Empty text logic usually handled before - - if all_exist and loaded_data: - cached_segments = loaded_data - except Exception as e: - print(f"Cache check error: {e}") - cached_segments = [] - - chunk_files = [] - sub_idx = 0 - base_name = f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_part{index}" - - # Function to process raw audio (from cache or gen) into final output - def process_and_save(graphemes, raw_audio): - nonlocal sub_idx - - # Post Process - processed_audio = self.process_audio(raw_audio, 24000, config) - - # Determine format - fmt = config.get('format', 'wav').lower() - if fmt not in ['wav', 'flac', 'mp3', 'ogg']: fmt = 'wav' - - file_name = f"{base_name}_{sub_idx}.{fmt}" - path = os.path.join(config['out_dir'], file_name) - - try: - # Use Pedalboard AudioFile for writing - with AudioFile(path, 'w', samplerate=24000, num_channels=1) as f: - f.write(processed_audio) - except Exception as e: - print(f"Pedalboard write failed: {e}. Fallback to soundfile.") - sf.write(path, processed_audio, 24000) - - return { - "path": path, - "text": graphemes, - "duration": len(processed_audio) / 24000.0, - "seg_idx": index - } - - if cached_segments: - # Use Cache - for graphemes, phonemes, audio in cached_segments: - if self.cancel_event.is_set(): break - if progress_callback: progress_callback(len(graphemes), graphemes) - - res = process_and_save(graphemes, audio) - chunk_files.append(res) - sub_idx += 1 - else: - # Generate - pipeline = get_thread_pipeline(lang_code) - if not pipeline: raise RuntimeError(f"Failed to initialize pipeline ({lang_code}) in thread.") - - generator = pipeline(text, voice=config['voice'], speed=eff_speed, split_pattern=config['split_pattern']) - - for graphemes, phonemes, audio in generator: - if self.cancel_event.is_set(): break - - # Notify progress - if progress_callback: - progress_callback(len(graphemes), graphemes) - - if isinstance(audio, torch.Tensor): - audio = audio.cpu().numpy() - - # Save to Cache if enabled - if use_cache and cache_hash: - cache_filename = f"{cache_hash}_{sub_idx}.wav" - cache_path = os.path.join(CACHE_DIR, cache_filename) - try: - sf.write(cache_path, audio, 24000) - except Exception as e: - print(f"Cache write error: {e}") - - # Process for output - res = process_and_save(graphemes, audio) - chunk_files.append(res) - sub_idx += 1 - - return chunk_files - - async def smart_combine(self, file_paths, output_path, update_callback): - def combine_worker(): - total_files = len(file_paths) - try: - # Use Pedalboard AudioFile - with AudioFile(output_path, 'w', samplerate=24000, num_channels=1) as out_f: - for i, fp in enumerate(file_paths): - if self.cancel_event.is_set(): break - try: - # Read with SoundFile (reliable for reading various formats) - data, _ = sf.read(fp) - out_f.write(data) - if update_callback: update_callback((i + 1) / total_files) - except Exception as e: - print(f"Failed to read segment {fp}: {e}") - except Exception as e: - print(f"Combine failed: {e}") - await asyncio.to_thread(combine_worker) - - def start_conversion(self, text, config): - # Resolve voice path once before distribution - config['voice'] = self.resolve_voice_path(config['voice']) - - self.cancel_event.clear() - self.worker.run_coro(self._process_text_async(text, config)) - def cancel(self): self.cancel_event.set() try: @@ -737,325 +141,3 @@ def cancel(self): playback.stop() except Exception: pass - - def start_jit_conversion(self, text, config): - """Starts real-time generation and playback.""" - config['voice'] = self.resolve_voice_path(config['voice']) - self.cancel_event.clear() - self.worker.run_coro(self._process_jit_async(text, config)) - - async def _process_jit_async(self, text, config): - """ - JIT Logic: - 1. Parse text into segments. - 2. Generation thread fills a queue. - 3. Playback thread consumes the queue. - 4. Buffer management (2 mins ahead). - """ - try: - if self.on_status: self.on_status("JIT: Preparing...", False) - os.makedirs(config['out_dir'], exist_ok=True) - - # 1. Parse segments - ms_segments = self.parse_multispeaker_text(text) - all_text_segments = [] - lexicon = config.get('lexicon', {}) - - for speaker_name, fx_name, segment_text in ms_segments: - segment_text = self.apply_lexicon(segment_text, lexicon) - seg_config = config.copy() - seg_config['format'] = 'wav' # Force wav for JIT playback compatibility - if speaker_name: - preset = self.load_preset(speaker_name) - if preset: - seg_config.update(preset) - if 'trim' in preset: - seg_config['trim_silence'] = preset['trim'] - seg_config['format'] = 'wav' # Ensure preset doesn't override format to non-wav - seg_config['voice'] = self.resolve_voice_path(seg_config['voice']) - - if fx_name: - fx_preset = self.load_fx_preset(fx_name) - if fx_preset: - seg_config.update(fx_preset) - seg_config['apply_fx'] = True - seg_config['fx_preset'] = fx_name - - # Split into smaller chunks for JIT (sentences/short paragraphs) - chunks = self.smart_split(segment_text, chunk_size=500) # Small chunks for fast start - for c in chunks: - all_text_segments.append((c, seg_config)) - - if not all_text_segments: - if self.on_status: self.on_status("No text for JIT.", False) - if self.on_finish: self.on_finish() - return - - # Queues and State - audio_queue = asyncio.Queue() - played_segments = [] - generated_but_unplayed = [] - total_segments = len(all_text_segments) - - playback_finished_event = asyncio.Event() - - # --- Generation Loop --- - async def generation_loop(): - nonlocal total_segments - try: - for i, (seg_text, seg_config) in enumerate(all_text_segments): - if self.cancel_event.is_set(): break - - while audio_queue.qsize() > 10 and not self.cancel_event.is_set(): - await asyncio.sleep(0.5) - - if self.cancel_event.is_set(): break - - if self.on_status: - self.on_status(f"JIT: Generating chunk {i+1}/{total_segments}...", False) - - chunk_files = await asyncio.to_thread(self.process_chunk_task, (i, seg_text, seg_config), None) - - for cf in chunk_files: - await audio_queue.put(cf) - generated_but_unplayed.append(cf) - except Exception as e: - print(f"JIT Gen Error: {e}") - finally: - # Always signal end - await audio_queue.put(None) - - # --- Playback Loop --- - async def playback_loop(): - nonlocal played_segments - start_time = time.time() - try: - idx = 0 - while not self.cancel_event.is_set(): - # Use wait_for to allow checking cancel_event periodically - try: - item = await asyncio.wait_for(audio_queue.get(), timeout=1.0) - except asyncio.TimeoutError: - continue - - if item is None: break # End of stream - - idx += 1 - if self.on_status: - self.on_status(f"JIT: Playing chunk {idx}...", False) - - clean_snip = item['text'].replace("\n", " ").strip() - if len(clean_snip) > 40: clean_snip = clean_snip[:37] + "..." - - elapsed = time.time() - start_time - if self.on_progress: - percent = (idx / total_segments) * 100 - self.on_progress(percent, elapsed, "--:--", f"Playing: {clean_snip}") - - # Play audio (Synchronously in thread) - await asyncio.to_thread(playback.play, item['path'], True) - - played_segments.append(item) - if item in generated_but_unplayed: - generated_but_unplayed.remove(item) - - except Exception as e: - print(f"JIT Playback Error: {e}") - finally: - playback_finished_event.set() - - # Start loops - gen_task = asyncio.create_task(generation_loop()) - play_task = asyncio.create_task(playback_loop()) - - await playback_finished_event.wait() - - # --- Cleanup and Save State --- - if self.cancel_event.is_set(): - if self.on_status: self.on_status("JIT Stopped. Saving state...", False) - else: - if self.on_status: self.on_status("JIT Finished.", False) - - # Combine what was played/generated so far - all_work_so_far = played_segments + generated_but_unplayed - if all_work_so_far: - combined_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_jit_output.wav") - await self.smart_combine([s['path'] for s in all_work_so_far], combined_path, None) - if self.on_status: self.on_status(f"JIT Output saved: {combined_path}", False) - - # Save remaining text - if generated_but_unplayed: - first_remaining_idx = generated_but_unplayed[0]['seg_idx'] - elif played_segments: - first_remaining_idx = played_segments[-1]['seg_idx'] + 1 - else: - first_remaining_idx = 0 - - remaining_text = "" - for i in range(first_remaining_idx, total_segments): - remaining_text += all_text_segments[i][0] + "\n\n" - - if remaining_text: - rem_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_remaining.txt") - with open(rem_path, "w", encoding="utf-8") as f: - f.write(remaining_text) - if self.on_status: self.on_status(f"Remaining text saved: {rem_path}", False) - - except Exception as e: - print(f"JIT Critical Error: {e}") - if self.on_status: self.on_status(f"JIT Error: {e}", True) - finally: - if self.on_finish: self.on_finish() - - async def _process_text_async(self, text, config): - try: - if self.on_status: self.on_status("Preparing text...", False) - os.makedirs(config['out_dir'], exist_ok=True) - - num_workers = config.get('num_threads', 1) - - # Multispeaker Support - ms_segments = self.parse_multispeaker_text(text) - tasks_data = [] - - lexicon = config.get('lexicon', {}) - - for speaker_name, fx_name, segment_text in ms_segments: - # Apply Lexicon - segment_text = self.apply_lexicon(segment_text, lexicon) - - seg_config = config.copy() - if speaker_name: - preset = self.load_preset(speaker_name) - if preset: - seg_config.update(preset) - if 'trim' in preset: - seg_config['trim_silence'] = preset['trim'] - # Resolve voice path for the new voice - seg_config['voice'] = self.resolve_voice_path(seg_config['voice']) - else: - if self.on_status: self.on_status(f"Warning: Preset '{speaker_name}' not found.", False) - - if fx_name: - fx_preset = self.load_fx_preset(fx_name) - if fx_preset: - seg_config.update(fx_preset) - seg_config['apply_fx'] = True - seg_config['fx_preset'] = fx_name - else: - if self.on_status: self.on_status(f"Warning: FX Preset '{fx_name}' not found.", False) - - # Split this segment into sub-chunks for parallel processing - # Use same character limit as original - seg_chunks = self.smart_split(segment_text, chunk_size=5000 if num_workers > 1 else 1000000) - for chunk in seg_chunks: - # (index, text, config) - tasks_data.append((len(tasks_data), chunk, seg_config)) - - total_chunks = len(tasks_data) - if total_chunks == 0: - if self.on_status: self.on_status("No text to process.", False) - if self.on_finish: self.on_finish() - return - - total_chars = sum(len(d[1]) for d in tasks_data) - processed_chars = 0 - start_time = time.time() - phase_weight = 0.9 if config.get('combine', True) else 1.0 - - if self.on_status: self.on_status(f"Queued {total_chunks} blocks. Starting {num_workers} workers...", False) - - # Progress tracker - progress_lock = threading.Lock() - - def on_chunk_progress(char_count, snippet): - nonlocal processed_chars - with progress_lock: - processed_chars += char_count - - # Calculate progress and call main callback - elapsed = time.time() - start_time - gen_fraction = min(processed_chars / total_chars, 1.0) - total_fraction = gen_fraction * phase_weight - - # Estimate ETA - eta_str = "--:--" - if total_fraction > 0.01: - total_est = elapsed / total_fraction - rem = max(0, total_est - elapsed) - eta_str = time.strftime('%M:%S', time.gmtime(rem)) - - clean_snip = snippet.replace("\n", " ").strip() - if len(clean_snip) > 40: clean_snip = clean_snip[:37] + "..." - - if self.on_progress: - self.on_progress(total_fraction * 100, elapsed, eta_str, f"Processing: {clean_snip}") - - # All generated files list - all_generated_files = [None] * total_chunks - - loop = asyncio.get_running_loop() - - with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: - futures = [] - for i, data in enumerate(tasks_data): - fut = loop.run_in_executor(executor, self.process_chunk_task, data, on_chunk_progress) - futures.append(fut) - - results = await asyncio.gather(*futures, return_exceptions=True) - - for i, result in enumerate(results): - if isinstance(result, Exception): - print(f"Chunk {i} failed: {result}") - if self.on_status: self.on_status(f"Error in chunk {i}", True) - else: - all_generated_files[i] = result - - if self.cancel_event.is_set(): - if self.on_status: self.on_status("Conversion Cancelled.", False) - if self.on_finish: self.on_finish() - return - - final_segment_list = [] - for sublist in all_generated_files: - if sublist: final_segment_list.extend(sublist) - - final_file_paths = [seg['path'] for seg in final_segment_list] - - if self.on_status: self.on_status(f"Generated {len(final_segment_list)} segments. Processing outputs...", False) - - if config.get('export_subtitles', False) and final_segment_list: - srt_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_combined.srt") - self.generate_srt(final_segment_list, srt_path) - - if config.get('combine', True) and final_file_paths: - if self.on_status: self.on_status("Merging audio files...", False) - - fmt = config.get('format', 'wav').lower() - combine_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_combined.{fmt}") - - def on_merge_progress(frac): - total_fraction = (1.0 * phase_weight) + (frac * (1.0 - phase_weight)) - elapsed = time.time() - start_time - if self.on_progress: - self.on_progress(total_fraction * 100, elapsed, "00:00", f"Merging... {int(frac*100)}%") - - await self.smart_combine(final_file_paths, combine_path, on_merge_progress) - - if not config.get('separate', True): - for p in final_file_paths: - try: os.remove(p) - except Exception: pass - - if self.on_status: self.on_status(f"Done! Saved: {combine_path}", False) - else: - if self.on_status: self.on_status("Conversion Complete!", False) - - if self.on_progress: - self.on_progress(100, time.time() - start_time, "00:00", "Completed") - - except Exception as e: - print(e) - if self.on_status: self.on_status(f"Critical Error: {e}", True) - finally: - if self.on_finish: self.on_finish() diff --git a/kokoro_gui/__init__.py b/kokoro_gui/__init__.py new file mode 100644 index 0000000..1db0138 --- /dev/null +++ b/kokoro_gui/__init__.py @@ -0,0 +1 @@ +APP_VERSION = "4.0.0-beta.1" diff --git a/kokoro_gui/audio/__init__.py b/kokoro_gui/audio/__init__.py new file mode 100644 index 0000000..8dd10bb --- /dev/null +++ b/kokoro_gui/audio/__init__.py @@ -0,0 +1,5 @@ +"""Audio-side helpers for the Qt shell: the position-tracking `Transport` +(transport.py) that plays a whole arrangement, as opposed to `playback.py`'s +fire-and-forget preview/JIT player, and the shared block mixer both it and +the offline exporter (`kokoro_gui.daw.mixdown`) use. +""" diff --git a/kokoro_gui/audio/mixer.py b/kokoro_gui/audio/mixer.py new file mode 100644 index 0000000..becb8da --- /dev/null +++ b/kokoro_gui/audio/mixer.py @@ -0,0 +1,90 @@ +"""Block mixing shared by the live `Transport` and the offline exporter. + +`LoadedClip` is one clip's mono float32 samples already at the mix sample +rate, positioned at `start_frame`. `mix_block` sums every clip overlapping +`[frame, frame + frames)` with a plain gain sum (grill Q21) and clips to ++-1. Pure numpy, no audio device, so the arithmetic is testable on its own. + +`load_clip_samples` reads a wav (or anything soundfile can open), applies +the clip's post-processing config (`kokoro_gui.audio.post`), downmixes to +mono and resamples to the target rate once; `post` memoizes the result by +`(path, mtime, post_key, target_rate)` so re-loading an unchanged +arrangement is free and an FX change re-renders only the clips it touched. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass + +import numpy as np + +@dataclass +class LoadedClip: + clip_id: str + start_frame: int + samples: np.ndarray # mono float32 + gain: float = 1.0 + + @property + def end_frame(self) -> int: + return self.start_frame + len(self.samples) + + +def resample(samples: np.ndarray, source_rate: int, target_rate: int) -> np.ndarray: + if source_rate == target_rate or len(samples) == 0: + return samples.astype(np.float32, copy=False) + try: + from math import gcd + + from scipy.signal import resample_poly + + g = gcd(int(source_rate), int(target_rate)) + return resample_poly(samples, target_rate // g, source_rate // g).astype(np.float32) + except Exception: + # Linear interpolation fallback - good enough for a playhead + # preview when scipy isn't importable. + duration = len(samples) / float(source_rate) + target_len = max(1, int(round(duration * target_rate))) + src_x = np.linspace(0.0, duration, num=len(samples), endpoint=False) + dst_x = np.linspace(0.0, duration, num=target_len, endpoint=False) + return np.interp(dst_x, src_x, samples).astype(np.float32) + + +def load_clip_samples(path: str, target_rate: int, post_config: dict | None = None) -> np.ndarray: + """Mono float32 at `target_rate`, post-processed per `post_config` + (`kokoro_gui.audio.post.render`, which owns the memo). Raises whatever + soundfile raises for an unreadable path - callers decide whether to skip + the clip.""" + from kokoro_gui.audio import post + + return post.render(path, post_config, int(target_rate)) + + +def clear_sample_cache() -> None: + from kokoro_gui.audio import post + + post.clear_render_cache() + + +def mix_block(clips: list, frame: int, frames: int, out: np.ndarray | None = None) -> np.ndarray: + """Sum of every `LoadedClip` overlapping `[frame, frame + frames)`, + clipped to +-1. `out`, if given, is a float32 array of length `frames` + that gets zeroed and filled in place.""" + if out is None: + out = np.zeros(frames, dtype=np.float32) + else: + out[:] = 0.0 + block_end = frame + frames + for clip in clips: + if clip.end_frame <= frame or clip.start_frame >= block_end: + continue + lo = max(frame, clip.start_frame) + hi = min(block_end, clip.end_frame) + src = clip.samples[lo - clip.start_frame:hi - clip.start_frame] + out[lo - frame:hi - frame] += src * clip.gain + np.clip(out, -1.0, 1.0, out=out) + return out + + +def total_frames(clips: list) -> int: + return max((c.end_frame for c in clips), default=0) diff --git a/kokoro_gui/audio/post.py b/kokoro_gui/audio/post.py new file mode 100644 index 0000000..3cc05c9 --- /dev/null +++ b/kokoro_gui/audio/post.py @@ -0,0 +1,102 @@ +"""Read-time post-processing for clip segments. + +Generation writes a clip's segments as raw model output (`Segment.raw`). +Everything the Audio FX tab and the Settings tab's volume / pitch / normalize +/ trim controls describe is applied here, when the transport, the exporter +or the timeline waveform reads the file, and never written back. Changing an +FX setting therefore never dirties a clip: `daw/dirty.py` only looks at the +generation keys, and this module only looks at `POST_KEYS`. + +`render()` memoizes by `(path, mtime, post_key, target_rate)`, so a slider +move re-renders only the clips whose resolved post config changed, and a +transport rebuild with nothing changed is a dict lookup per segment. + +Qt-free. `process_audio` is the same function `process_chunk_task` uses on +the whole-document path, called later. +""" +from __future__ import annotations + +import hashlib +import json +import os +from typing import Optional + +import numpy as np + +from kokoro_gui.engine.presets import ALLOWED_FX_PRESET_KEYS + +# Every config key the post stage reads. `pitch` is here (the resample) and +# also in the generation cache key (the speed compensation), which is why a +# pitch change both dirties the clip and re-renders it. +POST_KEYS = frozenset(ALLOWED_FX_PRESET_KEYS | {"apply_fx", "volume", "pitch", "normalize", "trim_silence"}) + +_RENDER_CACHE: dict = {} + + +def extract_post_config(config: dict) -> dict: + """The `POST_KEYS` subset of a full clip config.""" + return {k: config[k] for k in POST_KEYS if k in config} + + +def post_key(config: dict) -> str: + """A stable fingerprint of `config`'s post-processing keys. Key order and + non-post keys don't affect it.""" + subset = extract_post_config(config) + payload = json.dumps(subset, sort_keys=True, default=str) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def is_identity(config: dict) -> bool: + """True when `process_audio` would return its input unchanged, so + callers can skip the FX import entirely.""" + if config.get("trim_silence", False) or config.get("normalize", False): + return False + if config.get("volume", 1.0) != 1.0 or float(config.get("pitch", 0.0) or 0.0) != 0.0: + return False + if not config.get("apply_fx", True): + return True + for key in ALLOWED_FX_PRESET_KEYS: + if key.endswith("_enabled") and config.get(key, False): + return False + return not (config.get("eq_bass", 0.0) or config.get("eq_treble", 0.0)) + + +def _read_mono(path: str): + import soundfile as sf + + data, rate = sf.read(path, dtype="float32", always_2d=True) + mono = data.mean(axis=1).astype(np.float32) if data.shape[1] > 1 else data[:, 0] + return mono, int(rate) + + +def render(path: str, post_config: Optional[dict], target_rate: int) -> np.ndarray: + """`path` read, post-processed at its native rate per `post_config`, then + resampled to `target_rate`. Mono float32. Raises whatever soundfile + raises for an unreadable file. `post_config=None` means no processing.""" + from kokoro_gui.audio.mixer import resample + + try: + mtime = os.path.getmtime(path) + except OSError: + mtime = None + key = (os.path.abspath(path), mtime, post_key(post_config or {}), int(target_rate)) + cached = _RENDER_CACHE.get(key) + if cached is not None: + return cached + + mono, rate = _read_mono(path) + if post_config and not is_identity(post_config): + from kokoro_gui.engine.audio_fx import process_audio + + mono = np.asarray(process_audio(mono, rate, post_config), dtype=np.float32).reshape(-1) + out = resample(mono, rate, int(target_rate)) + _RENDER_CACHE[key] = out + return out + + +def rendered_duration_s(path: str, post_config: Optional[dict], target_rate: int) -> float: + return len(render(path, post_config, target_rate)) / float(target_rate) + + +def clear_render_cache() -> None: + _RENDER_CACHE.clear() diff --git a/kokoro_gui/audio/transport.py b/kokoro_gui/audio/transport.py new file mode 100644 index 0000000..3789694 --- /dev/null +++ b/kokoro_gui/audio/transport.py @@ -0,0 +1,289 @@ +"""Position-tracking arrangement player (UI4 of +Claude/PLAN_ui_shell_redesign.md, section 5). + +`playback.py` stays the fire-and-forget player for previews and JIT. This +`Transport` plays a whole arrangement: `load(schedule)` takes the +`ScheduledClip`s `kokoro_gui.daw.arrangement.compute_arrangement` produced +(estimated clips are skipped - they're silence), opens one +`sounddevice.OutputStream` at the project sample rate and fills each block +in the PortAudio callback by summing every clip overlapping it +(`kokoro_gui.audio.mixer.mix_block`). + +Position is the callback's frame counter, sample accurate, published to +the GUI thread by a 30Hz `QTimer` as `positionChanged(float)`. `play()`, +`pause()`, `stop()`, `seek()`, `toggle()` are GUI-thread API. + +`stream_factory` is injectable: tests pass a fake whose `pull(frames)` +drives the callback synchronously, so the mixing math and state machine +are testable without a device (same rule as `conftest.py`'s `playback` +mock). Without PortAudio (`playback.AVAILABLE` False) the transport still +loads and changes state, it just never advances. +""" +from __future__ import annotations + +import threading +from dataclasses import dataclass +from typing import Callable, Optional + +import numpy as np +from PySide6.QtCore import QObject, QTimer, Signal + +import playback +from kokoro_gui.audio import mixer + +POSITION_TIMER_MS = 33 +DEFAULT_SAMPLE_RATE = 24000 + + +@dataclass(frozen=True) +class ScheduledClip: + clip_id: str + start_s: float + path: Optional[str] + gain: float = 1.0 + # Read-time post-processing (kokoro_gui/audio/post.py) applied to + # `path` on load; None plays the file as is. + post_config: Optional[dict] = None + + +class _NullStream: + """Stands in for `sounddevice.OutputStream` when PortAudio is missing.""" + + def __init__(self, *_args, **_kwargs): + self.active = False + + def start(self): + self.active = True + + def stop(self): + self.active = False + + def close(self): + self.active = False + + +def default_stream_factory(sample_rate: int, callback: Callable): + if not playback.AVAILABLE or playback.sd is None: + return _NullStream() + return playback.sd.OutputStream(samplerate=sample_rate, channels=1, dtype="float32", callback=callback) + + +class Transport(QObject): + positionChanged = Signal(float) + stateChanged = Signal(str) # "playing" | "paused" | "stopped" + finished = Signal() + loaded = Signal() + + def __init__(self, parent=None, stream_factory: Optional[Callable] = None): + super().__init__(parent) + self._stream_factory = stream_factory or default_stream_factory + self._lock = threading.Lock() + self._clips: list = [] + self._sample_rate = DEFAULT_SAMPLE_RATE + self._frame = 0 + self._total_frames = 0 + self._ended = False + self._stream = None + self._state = "stopped" + self.loop = False + self._timer = QTimer(self) + self._timer.setInterval(POSITION_TIMER_MS) + self._timer.timeout.connect(self._on_tick) + + # -- introspection ----------------------------------------------------------- + + @property + def state(self) -> str: + return self._state + + @property + def is_playing(self) -> bool: + return self._state == "playing" + + @property + def sample_rate(self) -> int: + return self._sample_rate + + def position(self) -> float: + with self._lock: + frame = self._frame + return frame / float(self._sample_rate) + + def duration(self) -> float: + return self._total_frames / float(self._sample_rate) + + def loaded_clips(self) -> list: + with self._lock: + return list(self._clips) + + # -- loading ----------------------------------------------------------------- + + def load(self, schedule: list, sample_rate: Optional[int] = None, + total_duration_s: Optional[float] = None) -> None: + """Replace the arrangement. Keeps the current position and playing + state so a freshly generated clip becomes audible mid-playback (this + is also what `reload()` is for).""" + if sample_rate: + new_rate = int(sample_rate) + else: + new_rate = self._sample_rate + clips = [] + for item in schedule: + if not item.path: + continue + try: + samples = mixer.load_clip_samples(item.path, new_rate, item.post_config) + except Exception: + continue + clips.append(mixer.LoadedClip( + clip_id=item.clip_id, + start_frame=int(round(item.start_s * new_rate)), + samples=samples, + gain=item.gain, + )) + total = mixer.total_frames(clips) + if total_duration_s is not None: + total = max(total, int(round(total_duration_s * new_rate))) + + rate_changed = new_rate != self._sample_rate + with self._lock: + if rate_changed: + self._frame = int(round(self._frame * new_rate / float(self._sample_rate))) + self._sample_rate = new_rate + self._clips = clips + self._total_frames = total + self._frame = min(self._frame, total) + self._ended = False + if rate_changed and self._stream is not None: + was_playing = self.is_playing + self._close_stream() + if was_playing: + self._open_stream() + self.loaded.emit() + self.positionChanged.emit(self.position()) + + def reload(self, schedule: list, sample_rate: Optional[int] = None, + total_duration_s: Optional[float] = None) -> None: + self.load(schedule, sample_rate=sample_rate, total_duration_s=total_duration_s) + + # -- control ------------------------------------------------------------------- + + def play(self) -> None: + if self._total_frames == 0: + return + with self._lock: + if self._frame >= self._total_frames: + self._frame = 0 + self._ended = False + self._open_stream() + self._set_state("playing") + self._timer.start() + self.positionChanged.emit(self.position()) + + def pause(self) -> None: + if self._state != "playing": + return + self._close_stream() + self._timer.stop() + self._set_state("paused") + self.positionChanged.emit(self.position()) + + def stop(self) -> None: + self._close_stream() + self._timer.stop() + with self._lock: + self._frame = 0 + self._ended = False + self._set_state("stopped") + self.positionChanged.emit(0.0) + + def toggle(self) -> None: + if self.is_playing: + self.pause() + else: + self.play() + + def seek(self, seconds: float) -> None: + frame = int(round(max(0.0, seconds) * self._sample_rate)) + with self._lock: + self._frame = min(frame, self._total_frames) + self._ended = False + self.positionChanged.emit(self.position()) + + # -- internals ------------------------------------------------------------- + + def _set_state(self, state: str) -> None: + if state == self._state: + return + self._state = state + self.stateChanged.emit(state) + + def _open_stream(self) -> None: + if self._stream is not None: + return + self._stream = self._stream_factory(self._sample_rate, self._callback) + try: + self._stream.start() + except Exception: + self._stream = None + + def _close_stream(self) -> None: + stream = self._stream + self._stream = None + if stream is None: + return + for method in ("stop", "close"): + try: + getattr(stream, method)() + except Exception: + pass + + def _callback(self, outdata, frames, _time_info=None, _status=None) -> None: + """PortAudio callback thread. Never touches Qt.""" + with self._lock: + frame = self._frame + clips = self._clips + total = self._total_frames + loop = self.loop + block = mixer.mix_block(clips, frame, frames) + if outdata.ndim == 2: + outdata[:, 0] = block + if outdata.shape[1] > 1: + outdata[:, 1:] = block[:, None] + else: + outdata[:] = block + new_frame = frame + frames + ended = False + if new_frame >= total: + if loop and total > 0: + new_frame = new_frame % total + else: + new_frame = total + ended = True + with self._lock: + self._frame = new_frame + if ended: + self._ended = True + + def _on_tick(self) -> None: + with self._lock: + ended = self._ended + self.positionChanged.emit(self.position()) + if ended: + self._close_stream() + self._timer.stop() + with self._lock: + self._ended = False + self._set_state("stopped") + self.finished.emit() + + def process_pending(self) -> None: + """Test hook: what the timer tick does, callable synchronously.""" + self._on_tick() + + +def render_block_for_test(transport: Transport, frames: int) -> np.ndarray: + """Drives one callback synchronously and returns the mixed block.""" + out = np.zeros((frames, 1), dtype=np.float32) + transport._callback(out, frames) + return out[:, 0] diff --git a/kokoro_gui/daw/__init__.py b/kokoro_gui/daw/__init__.py new file mode 100644 index 0000000..c633f07 --- /dev/null +++ b/kokoro_gui/daw/__init__.py @@ -0,0 +1,11 @@ +"""Document/Clip/Segment/Track/Character data model for the "DAW for text" +redesign (see Claude/PLAN_daw_ui_ux_redesign.md and Claude/Kokorogui grill +chat.md for the design this package implements). + +This package deliberately has no Qt imports (same convention as +kokoro_gui/qt/spec.py) so it's testable as plain Python and importable from +non-GUI contexts. Nothing in kokoro_gui/qt wires this in yet - this is the +foundational data model only; the transcript editor, timeline widget, sync +layer, rescoped settings panel and consolidated action bar are later, +separately-planned passes that will consume it. +""" diff --git a/kokoro_gui/daw/arrangement.py b/kokoro_gui/daw/arrangement.py new file mode 100644 index 0000000..457e555 --- /dev/null +++ b/kokoro_gui/daw/arrangement.py @@ -0,0 +1,130 @@ +"""Where every clip sits on the seconds axis (UI9 of +Claude/PLAN_ui_shell_redesign.md). + +`compute_arrangement(document)` is the single source of truth the timeline +ruler, the clip renderer, the transport's playback schedule and the exporter +all read from. Qt-free, like the rest of `kokoro_gui/daw/`. + +Placement rule: walk clips in text order (`Document.clip_extent` start). +A clip with generated audio is as long as its segments say (or as long as +the caller's `clip_duration` measures them - the app passes one that +renders each segment through `kokoro_gui.audio.post`, since trim and pitch +change the length the raw file has); one without is +estimated from its text length at the engine's recorded chars/sec (UI11: +learned from `generation_stats.json`, 15 chars/s when there's no history), +divided by the clip's effective speed. A clip starts at its own +`timeline_timestamp` when the user has dragged it, else right where the +previous clip in text order ended - across every track, so the default is +one continuous read-through no matter how many lanes there are. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Optional + +FALLBACK_CHARS_PER_SECOND = 15.0 + + +@dataclass(frozen=True) +class PlacedClip: + clip: object + start_s: float + duration_s: float + estimated: bool + + @property + def end_s(self) -> float: + return self.start_s + self.duration_s + + +@dataclass(frozen=True) +class Arrangement: + placed: list + total_duration_s: float + + def by_clip_id(self) -> dict: + return {p.clip.id: p for p in self.placed} + + def at_time(self, seconds: float) -> list: + """Every placed clip whose span covers `seconds`.""" + return [p for p in self.placed if p.start_s <= seconds < p.end_s] + + +def recorded_chars_per_second(engine_id: Optional[str]) -> Optional[float]: + """The engine's recorded throughput from `generation_stats.json`, or + None. Imported lazily: stats.py imports `kokoro_engine`, which pulls in + the kokoro package - too heavy to make a hard dependency of this + otherwise-pure module (and its tests).""" + try: + from kokoro_gui.engine.stats import estimate_chars_per_sec + except Exception: + return None + try: + return estimate_chars_per_sec(engine_id or "kokoro") + except Exception: + return None + + +def estimate_duration_s(text: str, speed: float, chars_per_second: Optional[float]) -> float: + rate = chars_per_second if chars_per_second and chars_per_second > 0 else FALLBACK_CHARS_PER_SECOND + speed = speed if speed and speed > 0 else 1.0 + chars = len(text.strip()) + if chars == 0: + return 0.0 + return chars / (rate * speed) + + +def clip_audio_duration_s(clip) -> Optional[float]: + """Sum of the clip's segment durations, or None when no segment carries + audio yet.""" + if not any(getattr(s, "audio_path", None) for s in clip.segments): + return None + return float(sum(s.duration or 0.0 for s in clip.segments)) + + +def compute_arrangement(document, engine_id: Optional[str] = None, + chars_per_second: Optional[float] = None, + clip_duration: Optional[Callable] = None) -> Arrangement: + """Pass `chars_per_second` to bypass the stats lookup (tests, or a + caller that already has the number). `clip_duration(clip)` replaces + `clip_audio_duration_s` when given: it returns the clip's audible + length in seconds, or None for a clip with no audio yet.""" + if chars_per_second is None: + chars_per_second = recorded_chars_per_second(engine_id) + if clip_duration is None: + clip_duration = clip_audio_duration_s + + with_extent = [] + for clip in document.clips: + extent = document.clip_extent(clip.id) + if extent is None: + continue + with_extent.append((extent[0], clip)) + with_extent.sort(key=lambda pair: pair[0]) + + placed = [] + cursor = 0.0 + for _start_offset, clip in with_extent: + audio_duration = clip_duration(clip) + if audio_duration is not None: + duration = audio_duration + estimated = False + else: + config = document.effective_config_for_clip(clip) + duration = estimate_duration_s(document.clip_text(clip), config.get("speed", 1.0), chars_per_second) + estimated = True + start = clip.timeline_timestamp if clip.timeline_timestamp is not None else cursor + start = max(0.0, float(start)) + placed.append(PlacedClip(clip=clip, start_s=start, duration_s=duration, estimated=estimated)) + cursor = start + duration + + total = max((p.end_s for p in placed), default=0.0) + return Arrangement(placed=placed, total_duration_s=total) + + +def text_order_predecessor(arrangement: Arrangement, clip_id: str): + """The placed clip immediately before `clip_id` in text order, or None.""" + for index, placed in enumerate(arrangement.placed): + if placed.clip.id == clip_id: + return arrangement.placed[index - 1] if index > 0 else None + return None diff --git a/kokoro_gui/daw/auto_split.py b/kokoro_gui/daw/auto_split.py new file mode 100644 index 0000000..7d94a7c --- /dev/null +++ b/kokoro_gui/daw/auto_split.py @@ -0,0 +1,115 @@ +"""Auto-split planning (item 7, "Auto-split on generation + combined-vs- +separate clip generation", of the DAW-for-text remaining-work roadmap): +turns a `[Speaker:FX]:`-tagged document (and, optionally, its untagged +narration) into a list of `(start, end, character_id)` triples ready to feed +into `Document.assign_character_to_range`-shaped calls - the automated +equivalent of invoking the transcript panel's Characters menu once per tag +(or, in "auto-split" mode, once per paragraph within each tag's span). + +Pure planning, no Qt: `plan_auto_split_clips` never mutates `document` - the +caller (`QtTTSApp.auto_split_and_generate`) applies the plan by pushing one +`AssignCharacterCommand` (kokoro_gui/daw/undo.py) per triple, in ascending +`start` order, so the result is undoable like every other clip-creating +action in the app. Applying them in that order against the same, unchanging +`document.text` is safe: none of these commands are text edits, so no +triple's offsets are invalidated by an earlier one being applied first (see +`Document.assign_character_to_range`'s docstring - it only adds/removes/ +splits clips, never touches `document.text`). + +Two decisions this module encodes (settled in the roadmap's item 7 write-up, +not re-derived here): + +- "Combined" mode = one triple per matched `[Speaker:FX]:` span (the whole + block of text between one tag and the next, tag markup included - exactly + `find_character_fx_spans`'s own `(start, end)`). "Auto-split" mode = the + same spans, each additionally split on blank-line/paragraph boundaries - + the same splitting convention `TextExtractionMixin.smart_split` uses for + `\n\n`, replicated here (`_paragraph_ranges` below) with real offsets into + the original document text rather than `smart_split`'s own detached text + pieces, and skipping empty/whitespace-only pieces exactly like + `smart_split`'s own `[c for c in chunks if c.strip()]` does. +- Untagged narration only gets auto-clipped when `document.characters` has + EXACTLY ONE character - use that one, unambiguously. Zero or two-or-more + characters leaves untagged stretches without a clip, exactly matching + today's existing "some text just has no clip" state for text nobody's + manually assigned a character to. +""" +from __future__ import annotations + +from kokoro_gui.engine.text_extraction import find_character_fx_spans + + +def _paragraph_ranges(text: str, base_offset: int) -> list: + """Splits `text` (a substring of the document starting at `base_offset` + in the document's own coordinates) into `(start, end)` ranges on + `smart_split`'s `text.split('\n\n')` boundary rule, translated back into + absolute document offsets, skipping empty/whitespace-only pieces.""" + ranges = [] + cursor = base_offset + pieces = text.split("\n\n") + for i, piece in enumerate(pieces): + piece_start = cursor + piece_end = cursor + len(piece) + if piece.strip(): + ranges.append((piece_start, piece_end)) + cursor = piece_end + if i < len(pieces) - 1: + cursor += 2 # the "\n\n" separator consumed by str.split, restored + return ranges + + +def _untagged_gaps(text: str, covered: list) -> list: + """The complement of `covered` (a list of `(start, end)` ranges, already + in ascending offset order since `find_character_fx_spans` returns + offset-ordered spans) within `[0, len(text))`.""" + gaps = [] + cursor = 0 + for start, end in covered: + if start > cursor: + gaps.append((cursor, start)) + cursor = max(cursor, end) + if cursor < len(text): + gaps.append((cursor, len(text))) + return gaps + + +def plan_auto_split_clips(document, split_by_paragraph: bool): + """Returns `(triples, unmatched_span_names)`: + + - `triples`: `list[(start, end, character_id)]`, ascending `start` order. + - `unmatched_span_names`: the `speaker_name` of every tagged span that + matched no existing `Character` (via `Document.get_character_by_name`) + - those spans contribute zero triples and never raise; the caller + surfaces this list as a warning rather than silently dropping it. + """ + text = document.text + spans = find_character_fx_spans(text) + + triples = [] + unmatched = [] + covered = [] + + for span in spans: + covered.append((span.start, span.end)) + character = document.get_character_by_name(span.speaker_name) + if character is None: + unmatched.append(span.speaker_name) + continue + + if split_by_paragraph: + for p_start, p_end in _paragraph_ranges(text[span.start:span.end], span.start): + triples.append((p_start, p_end, character.id)) + else: + triples.append((span.start, span.end, character.id)) + + if len(document.characters) == 1: + only_character = document.characters[0] + for gap_start, gap_end in _untagged_gaps(text, covered): + if split_by_paragraph: + for p_start, p_end in _paragraph_ranges(text[gap_start:gap_end], gap_start): + triples.append((p_start, p_end, only_character.id)) + elif text[gap_start:gap_end].strip(): + triples.append((gap_start, gap_end, only_character.id)) + + triples.sort(key=lambda t: t[0]) + return triples, unmatched diff --git a/kokoro_gui/daw/dirty.py b/kokoro_gui/daw/dirty.py new file mode 100644 index 0000000..f4373e8 --- /dev/null +++ b/kokoro_gui/daw/dirty.py @@ -0,0 +1,140 @@ +"""Dirty/stale detection for `Clip`s (Q2/Q3: editing already-generated text +marks it dirty rather than auto-regenerating; the app waits for an explicit +scoped Generate action). + +There is no stored "is_dirty" flag on `Clip` - it's computed on demand by +re-deriving the segment key `process_chunk_task` +(kokoro_gui/engine/caching.py) would compute for the clip's *current* text +and generation inputs, and comparing that against what its `Segment`s +recorded from the last successful generation. This keeps dirtiness a pure +function of current state instead of a flag some code path could forget to +set or clear. + +Only generation inputs count. FX, volume, pitch's resample, normalize and +trim are read-time post-processing (kokoro_gui/audio/post.py) and changing +them never dirties a clip; `Segment.raw` is the one post-related check here, +and it only catches segments generated before that was true. + +The key itself comes from `caching.segment_key`, which needs the backend +(to resolve and fingerprint a voice file, and for Audio8 to read the +transcript). This daw layer has no backend, so the app injects a key +function: `Document.segment_key_fn`, a memoized closure +`(text, clip, engine_version=None) -> key` (kokoro_gui/qt/app.py). Without +one (tests, headless use) `compute_expected_cache_hash` falls back to the +name-only `compute_cache_key`, which is the same value for a built-in voice +on Kokoro. + +Two rules from the `.tbaw` plan (Claude/old/PLAN_tbaw_bundle.md section 2.3): +a segment is compared under the engine version it was generated with while +its file exists (TB9: a bundle from another machine's model version opens +clean), and a segment whose `audio_path` is set but whose file is missing is +dirty (TB11: close-time GC and an undo across it can't leave a clip playing +silence). +""" +import os + +from kokoro_gui.daw.models import Segment +from kokoro_gui.engine import caching +from kokoro_gui.engine.caching import compute_cache_key, effective_speed + + +def compute_expected_cache_hash(text: str, config: dict, engine_version=None, key_fn=None, clip=None) -> str: + """The segment key a generation over `text`/`config` would produce right + now. With `key_fn` (the app's closure) that is `caching.segment_key` + over the clip's real generation inputs; without one it's the name-only + `compute_cache_key` over `config["voice"]`, defaulting `lang_code` to + "a" the way the pre-bundle check did. `engine_version=None` means the + installed one.""" + if key_fn is not None: + return key_fn(text, clip, engine_version) + lang_code = config.get("lang_code", "a") + engine_id = config.get("engine_id", "kokoro") + return compute_cache_key(text, config.get("voice"), effective_speed(config), lang_code, engine_id, + engine_version=engine_version) + + +def predict_segment_texts(text: str, config: dict) -> list: + """The sub-segment texts `process_chunk_task` would expect to find cached + (or generate) for `text`/`config` - the same prediction caching.py + makes, read from there rather than duplicated.""" + return caching.predict_segment_texts(text, config.get("split_pattern", r"\n+")) + + +def segment_file_missing(segment) -> bool: + """True when the segment names a file that isn't there. A segment with + no `audio_path` at all (a test fixture, a pre-audio record) isn't + "missing", it's simply not backed by a file.""" + return bool(segment.audio_path) and not os.path.isfile(segment.audio_path) + + +def is_clip_dirty(clip, text: str, config: dict, key_fn=None) -> bool: + """True if `clip` needs (re)generation: it has never been generated, or + its current text/generation inputs no longer match what its stored + `Segment`s were generated from (Q16: an in-place edit keeps the same + `Clip` object, now dirty; models.Document.replace_text is what + guarantees a fully-deleted-then-retyped clip never reaches this function + as the *same* object in the first place - see Q18), or a segment's file + is gone.""" + if not clip.segments: + return True + # A segment baked with its FX at generation time (pre non-destructive + # FX) can't be post-processed again without doubling the effect. + if any(not segment.raw for segment in clip.segments): + return True + + expected_count = len(predict_segment_texts(text, config)) + if len(clip.segments) != expected_count: + return True + + expected_by_version = {} + for segment in clip.segments: + if segment_file_missing(segment): + return True + # Stored keys win while the file is there (TB9). A missing file has + # to regenerate with what's installed, and that's the version the + # `None` key looks up. + version = segment.engine_version if segment.audio_path else None + if version not in expected_by_version: + expected_by_version[version] = compute_expected_cache_hash( + text, config, engine_version=version, key_fn=key_fn, clip=clip, + ) + if segment.cache_key != expected_by_version[version]: + return True + return False + + +def build_segments_from_results(expected_hash, results: list) -> list: + """Builds the `Segment` list for a clip from a `generate_clip_audio`/ + `process_chunk_task` result list - one implementation shared by the + single-clip and batch Generate paths (kokoro_gui/qt/docks/timeline_dock.py). + + `cache_key` and `engine_version` come from each result dict: the engine + reports what it generated under, including any take bump (TB8), so + nothing predicts the key before dispatch. `expected_hash` is the + fallback for a result built by hand without a `cache_key`. + + `order_index` comes from `enumerate(results)`, NOT each result dict's + `seg_idx` field: every sub-segment of one `process_chunk_task` call + shares the same `seg_idx` (the chunk's outer index), so using it + directly would give every `Segment` in a multi-segment clip + `order_index=0`, breaking `is_clip_dirty`'s segment-count comparison. + + `raw` comes from each result's "raw" field (every backend's + `process_chunk_task` sets it; `generate_clip_audio` always requests raw + output) and defaults True for a result dict built by hand. + """ + return [ + Segment(order_index=i, text=result["text"], cache_key=result.get("cache_key") or expected_hash, + audio_path=result["path"], duration=result["duration"], + raw=bool(result.get("raw", True)), engine_version=result.get("engine_version")) + for i, result in enumerate(results) + ] + + +def take_from_results(results: list, default: int = 0) -> int: + """The take index the engine landed on for a clip, read off its results + (they all carry the same one); `default` when the results don't say.""" + for result in results: + if "take" in result: + return int(result["take"] or 0) + return default diff --git a/kokoro_gui/daw/migration.py b/kokoro_gui/daw/migration.py new file mode 100644 index 0000000..fd995f5 --- /dev/null +++ b/kokoro_gui/daw/migration.py @@ -0,0 +1,80 @@ +"""First-load migration from today's `presets/*.json` + `config_qt.json` +settings into a `Document`. + +There is no document *text* to migrate: `GenerationDock.text_entry`'s +content was never written to `config_qt.json` (`save_settings` never +persists the text box), so a fresh `Document` always starts with empty text +and no clips - only the presets/settings side has anything to carry +forward. + +Not called anywhere yet: nothing in `kokoro_gui/qt` consumes `Document` until +the transcript-panel/timeline workstreams land. This module exists now so +its behavior is locked in and tested before the GUI depends on it. +""" +import json +import os + +from kokoro_gui.daw.models import DEFAULT_HIGHLIGHT_PALETTE, Character, Document, Track +from kokoro_gui.engine.presets import ALLOWED_PRESET_KEYS, filter_allowed_keys + +DEFAULT_CHARACTER_NAME = "Default" + + +def _load_preset_files(presets_dir: str) -> dict: + """Reads every `/*.json` file into `{name: preset_dict}`, + skipping the `fx/` subdirectory (FX presets, not speaker presets) and + any file that fails to parse. Deliberately plain `json.load` rather than + `PresetsMixin.load_preset` - that method requires an engine instance and + hardcodes `"presets"` as a relative path, neither of which fits a + migration helper that needs to run standalone against an arbitrary + directory in tests.""" + presets = {} + if not os.path.isdir(presets_dir): + return presets + + for entry in sorted(os.listdir(presets_dir)): + full_path = os.path.join(presets_dir, entry) + if not entry.endswith(".json") or not os.path.isfile(full_path): + continue + name = entry[: -len(".json")] + try: + with open(full_path, "r", encoding="utf-8") as f: + presets[name] = json.load(f) + except (OSError, json.JSONDecodeError): + continue + return presets + + +def migrate_legacy_settings_to_document(settings: dict, presets_dir: str) -> Document: + """Builds a fresh `Document` from today's presets directory and app + settings: + + 1. Every `presets/*.json` file becomes a `Character`, via + `Character.from_preset_dict` - the JSON files themselves are left + untouched (a safe downgrade path). + 2. If no presets exist yet, one "Default" `Character` is seeded from + `settings`'s current voice/speed/etc. so a returning user's + last-used config isn't silently discarded. + 3. One default `Track` is created per `Character` (Q8's auto-placement + default - a character gets an obvious lane to land clips on before + any manual re-sorting happens). + """ + characters = [] + for index, (name, preset_data) in enumerate(sorted(_load_preset_files(presets_dir).items())): + color = DEFAULT_HIGHLIGHT_PALETTE[index % len(DEFAULT_HIGHLIGHT_PALETTE)] + characters.append(Character.from_preset_dict(name, preset_data, highlight_color=color)) + + if not characters: + seeded = filter_allowed_keys(settings or {}, ALLOWED_PRESET_KEYS) + characters.append( + Character.from_preset_dict( + DEFAULT_CHARACTER_NAME, seeded, highlight_color=DEFAULT_HIGHLIGHT_PALETTE[0] + ) + ) + + tracks = [ + Track(name=character.name, character_id=character.id, order_index=i) + for i, character in enumerate(characters) + ] + + return Document(runs=[], clips=[], tracks=tracks, characters=characters, settings={}) diff --git a/kokoro_gui/daw/mixdown.py b/kokoro_gui/daw/mixdown.py new file mode 100644 index 0000000..c4c1a56 --- /dev/null +++ b/kokoro_gui/daw/mixdown.py @@ -0,0 +1,171 @@ +"""Offline export of a clip document (section 6 of +Claude/PLAN_ui_shell_redesign.md, WF8). + +`mixdown()` walks `compute_arrangement` (the same placement the timeline +and transport use), reads every clip's segments through the same read-time +post-processing the transport plays (`kokoro_gui.audio.post`, via +`post_config_for_clip`), sums them at their start times with the same +plain-gain-sum rule the live `Transport` applies (grill Q21), and writes one +file. SRT rows come straight from each `PlacedClip`'s +start/duration and the clip's text, replacing `SrtMixin`'s segment-timing +walk for clip documents (that mixin stays for the no-clips whole-document +path). + +"Keep per-clip files" writes one file per clip next to the mixdown, named +`__.` (UI14), the character name +basename-sanitized like every other name-to-path site in this codebase. + +No Qt here, and the audio math is the shared `kokoro_gui.audio.mixer`, so +the whole thing is testable headlessly with a stub document. +""" +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from typing import Callable, Optional + +import numpy as np + +from kokoro_gui.audio import mixer +from kokoro_gui.daw.arrangement import Arrangement, compute_arrangement + +SOUNDFILE_FORMATS = {"wav", "flac", "ogg"} + + +@dataclass +class ExportResult: + audio_path: str + srt_path: Optional[str] = None + clip_files: list = field(default_factory=list) + duration_s: float = 0.0 + skipped_clip_ids: list = field(default_factory=list) + + +def _format_srt_time(seconds: float) -> str: + millis = int(round((seconds - int(seconds)) * 1000)) + whole = int(seconds) + if millis == 1000: + whole += 1 + millis = 0 + minutes, secs = divmod(whole, 60) + hours, minutes = divmod(minutes, 60) + return f"{hours:02}:{minutes:02}:{secs:02},{millis:03}" + + +def write_srt(document, arrangement: Arrangement, path: str) -> str: + rows = [] + index = 1 + for placed in arrangement.placed: + if placed.estimated: + continue + text = document.clip_text(placed.clip).strip() + if not text: + continue + rows.append(f"{index}\n{_format_srt_time(placed.start_s)} --> {_format_srt_time(placed.end_s)}\n{text}\n") + index += 1 + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(rows)) + if rows: + f.write("\n") + return path + + +def _safe_component(name: str) -> str: + """A character name as a filename piece: separators and other unsafe + characters become "_" first (so "Bo b/ok" keeps both halves), then the + same `os.path.basename()` guard every other name-to-path site uses.""" + name = re.sub(r'[<>:"/\\|?*\s]+', "_", (name or "").strip()) + name = os.path.basename(name) + return name or "clip" + + +def _clip_samples(clip, sample_rate: int, post_config: Optional[dict] = None) -> Optional[np.ndarray]: + """All of a clip's segments concatenated at `sample_rate`, post-processed + per `post_config`, or None if none of them can be read.""" + parts = [] + for segment in sorted(clip.segments, key=lambda s: s.order_index): + if not segment.audio_path: + continue + try: + parts.append(mixer.load_clip_samples(segment.audio_path, sample_rate, post_config)) + except Exception: + continue + if not parts: + return None + return np.concatenate(parts).astype(np.float32) + + +def write_audio(path: str, samples: np.ndarray, sample_rate: int, fmt: str) -> None: + fmt = (fmt or "wav").lower() + if fmt in SOUNDFILE_FORMATS: + import soundfile as sf + + sf.write(path, samples, sample_rate, format=fmt.upper()) + return + # mp3 and anything else soundfile can't encode: the same pedalboard + # AudioFile path ConversionMixin.smart_combine uses. + from pedalboard.io import AudioFile + + with AudioFile(path, "w", samplerate=sample_rate, num_channels=1) as out_f: + out_f.write(samples.reshape(1, -1)) + + +def mixdown(document, out_path: str, fmt: str = "wav", sample_rate: int = 24000, + include_srt: bool = False, keep_clip_files: bool = False, + arrangement: Optional[Arrangement] = None, engine_id: Optional[str] = None, + progress: Optional[Callable[[float, str], None]] = None, + post_config_for_clip: Optional[Callable] = None) -> ExportResult: + """`post_config_for_clip(clip)` returns the clip's resolved read-time + post-processing config (the app passes `QtTTSApp.post_config_for_clip`); + None exports the raw segment files as they are.""" + if arrangement is None: + arrangement = compute_arrangement(document, engine_id=engine_id) + sample_rate = int(sample_rate) + out_dir = os.path.dirname(out_path) or "." + os.makedirs(out_dir, exist_ok=True) + base = os.path.splitext(os.path.basename(out_path))[0] + ext = (fmt or "wav").lower() + + loaded: list = [] + skipped: list = [] + per_clip: list = [] + total = max(1, len(arrangement.placed)) + for index, placed in enumerate(arrangement.placed): + if progress: + progress(index / total * 0.8, f"Reading clip {index + 1}/{total}") + post_config = post_config_for_clip(placed.clip) if post_config_for_clip else None + samples = _clip_samples(placed.clip, sample_rate, post_config) + if samples is None: + skipped.append(placed.clip.id) + continue + loaded.append(mixer.LoadedClip( + clip_id=placed.clip.id, + start_frame=int(round(placed.start_s * sample_rate)), + samples=samples, + )) + per_clip.append((index, placed, samples)) + + total_frames = max(mixer.total_frames(loaded), int(round(arrangement.total_duration_s * sample_rate))) + mixed = mixer.mix_block(loaded, 0, total_frames) if total_frames > 0 else np.zeros(0, dtype=np.float32) + if progress: + progress(0.85, "Writing mixdown") + write_audio(out_path, mixed, sample_rate, ext) + + result = ExportResult(audio_path=out_path, duration_s=total_frames / float(sample_rate), skipped_clip_ids=skipped) + + if keep_clip_files: + for index, placed, samples in per_clip: + character = document.get_character(placed.clip.character_id) + who = _safe_component(character.name if character is not None else "clip") + clip_path = os.path.join(out_dir, f"{base}_{index + 1:03d}_{who}.{ext}") + write_audio(clip_path, samples, sample_rate, ext) + result.clip_files.append(clip_path) + + if include_srt: + srt_path = os.path.join(out_dir, f"{base}.srt") + result.srt_path = write_srt(document, arrangement, srt_path) + + if progress: + progress(1.0, "Export finished") + return result diff --git a/kokoro_gui/daw/models.py b/kokoro_gui/daw/models.py new file mode 100644 index 0000000..7d4910a --- /dev/null +++ b/kokoro_gui/daw/models.py @@ -0,0 +1,594 @@ +"""Core dataclasses: Document, Run, Clip, Segment, Track, Character. + +Resolves the "grill chat" architecture (Claude/Kokorogui grill chat.md, Q1-Q29 +plus the Text Editor Grill TE1-TE6) into concrete types. As of +Claude/PLAN_text_editor_redesign.md, the offset-based `Clip` (`start_offset`/ +`end_offset` into a flat `Document.text`) has been replaced by a **tagged +run list**: `Document.runs` is the authoritative structure, each `Run` a +contiguous stretch of text carrying (at most) one `Clip.id`. A clip's extent +is wherever its id is applied across the run list, found by walking runs - +not stored as numbers that need shifting on every edit. `Document.text` +remains available as a *derived* property (the join of every run's text), +used only for TTS generation, cache-key input, word count, and export - it +is never the authoritative field. + +- A `Clip` is a user-facing unit that may map to multiple engine-level + `Segment`s (Q3) - the engine's own text splitting (KPipeline's + `split_pattern`) stays internal to a clip, not surfaced as the primary + structure. +- `Clip.id` is a UUID, never derived from content - this is what keeps cache + identity (a content hash, see kokoro_gui/engine/caching.py) and clip + identity (Q18) genuinely independent: deleting a clip removes the object + entirely (and untags its runs), and a later, coincidentally-identical clip + gets a fresh id and default metadata even though its audio may still + cache-hit. +- `Track` is a lane keyed by `character_id` for auto-placement (Q8) - kept as + a separate object from `Character` on purpose, since a clip can be dragged + onto a track whose `character_id` differs from the clip's own (Q9). +- `Character` wraps the existing `presets/*.json` shape (see + kokoro_gui/engine/presets.py's `ALLOWED_PRESET_KEYS`) rather than + reinventing preset storage. +""" +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from typing import Callable, Optional + +from kokoro_gui.daw.undo import UndoStack +from kokoro_gui.engine.presets import ALLOWED_PRESET_KEYS, filter_allowed_keys + +# Small fixed palette cycled by migration.py when assigning default +# highlight colors to characters created from existing presets - not a +# proposed app-wide color scheme, just distinguishable defaults a user can +# change later from the (future) Characters menu, per the design doc's note +# that per-character highlight colors are one of the few places this redesign +# does make an actual color decision. +# Eight hues at similar lightness so any of them takes the same label +# color on a clip and tints the transcript evenly in both themes; the +# Characters dialog offers the same eight as the color picker's presets. +DEFAULT_HIGHLIGHT_PALETTE = ( + "#e3a72f", # amber + "#4f8fe6", # blue + "#e0655c", # coral + "#3fae7a", # green + "#a76fd6", # purple + "#3bb3c4", # teal + "#e78a3e", # orange + "#d75c9a", # pink +) + + +def _new_id() -> str: + return uuid.uuid4().hex + + +@dataclass +class Character: + """A named, reusable voice/settings preset - Q7's "character = a preset + of settings". Wraps a `presets/*.json`-shaped dict (`preset_data`) + instead of reinventing preset storage, so + `PresetsMixin.load_preset`/`load_fx_preset` + (kokoro_gui/engine/presets.py) keep working unchanged.""" + + name: str + preset_data: dict = field(default_factory=dict) + highlight_color: str = DEFAULT_HIGHLIGHT_PALETTE[0] + backend_id: str = "kokoro" + id: str = field(default_factory=_new_id) + # Fields this version doesn't know, carried through a load/save so an + # older KokoroGUI doesn't strip what a newer one wrote (see + # serialization.py). Nothing in the app reads it. For a Character it + # also holds `extra["preset_data"]`: the preset keys ALLOWED_PRESET_KEYS + # strips from what reaches a config dict. + extra: dict = field(default_factory=dict) + + @classmethod + def from_preset_dict(cls, name, preset_data, highlight_color=None, backend_id="kokoro", id=None): + """Wraps a preset dict already loaded via `PresetsMixin.load_preset` + (or an equivalent plain `json.load`) into a `Character`. Only keys in + `ALLOWED_PRESET_KEYS` are kept, mirroring the same untrusted-preset + whitelist `filter_allowed_keys` enforces elsewhere - a `Character` + should never carry more trust than a raw preset file already has.""" + kwargs = {"name": name, "preset_data": filter_allowed_keys(preset_data or {}, ALLOWED_PRESET_KEYS)} + if highlight_color is not None: + kwargs["highlight_color"] = highlight_color + if backend_id is not None: + kwargs["backend_id"] = backend_id + if id is not None: + kwargs["id"] = id + return cls(**kwargs) + + def to_preset_dict(self) -> dict: + """The plain dict shape `PresetsMixin`'s preset JSON files use - the + inverse of `from_preset_dict`, for saving a `Character`'s settings + back out as a `presets/.json` file.""" + return dict(self.preset_data) + + +@dataclass +class Track: + """An organizational timeline lane. Not identical to a `Character` (Q8) - + kept separate so a clip can be dragged onto a track belonging to a + different character (Q9's reassign-vs-move prompt).""" + + name: str + character_id: Optional[str] = None + order_index: int = 0 + id: str = field(default_factory=_new_id) + extra: dict = field(default_factory=dict) # unknown fields, see Character + + +@dataclass +class Segment: + """One engine-level generation unit inside a `Clip` - a persisted record + of a `(index, text, config)` tuple already handled by + `CachingMixin.process_chunk_task` (kokoro_gui/engine/caching.py). Not a + new execution concept: `cache_key` is the chunk-level hash + `compute_cache_key` returns for the clip's full text (see + kokoro_gui/daw/dirty.py), and `order_index` is that hash's `_{i}` file + suffix (`caching.py`'s `sub_idx`) - multiple segments of one clip share + the same `cache_key` and differ only by `order_index`. + + `raw` is True when `audio_path` holds unprocessed model output + (`process_chunk_task` with `config['raw_output']`), which is what every + clip generated since non-destructive FX landed is, so it defaults True; + FX, volume, pitch, normalize and trim are applied on read by + `kokoro_gui.audio.post`. `raw=False` marks a segment baked with its FX at + generation time: `serialization.document_from_dict` assigns it to a + saved segment that predates the flag, and `dirty.is_clip_dirty` reports + such a clip dirty so it regenerates once (a cache hit when caching is + on) instead of getting FX applied twice. `duration` is the raw length; + the arrangement measures the rendered length itself. + + `cache_key` is `caching.segment_key` for the clip's text and generation + inputs, and it is also the stem of the file `audio_path` names in a + project dir (`_.`). `engine_version` is the + version string the backend reported when this segment was generated; + the dirty check keys with it while the file is present, so a project + made with one model version opens clean on a machine with another + (grill TB9). `None` means "written before the field", which the dirty + check treats as the installed version.""" + + order_index: int = 0 + text: str = "" + cache_key: Optional[str] = None + audio_path: Optional[str] = None + duration: Optional[float] = None + raw: bool = True + engine_version: Optional[str] = None + id: str = field(default_factory=_new_id) + extra: dict = field(default_factory=dict) # unknown fields, see Character + + +@dataclass +class Clip: + """A user-facing unit of text-anchored audio (Q19: every clip, imported + or generated, is text-anchored). Unlike the retired offset-based model, + a `Clip` no longer stores its own extent - that's wherever `Document.runs` + tags a run with this clip's `id` (see `Document.clip_extent`/ + `Document.clip_text`).""" + + character_id: Optional[str] = None + track_id: Optional[str] = None + overrides: dict = field(default_factory=dict) + fx_override: Optional[dict] = None + timeline_timestamp: Optional[float] = None + segments: list = field(default_factory=list) + source: str = "generated" # "generated" | "imported" + original_audio_path: Optional[str] = None + id: str = field(default_factory=_new_id) + extra: dict = field(default_factory=dict) # unknown fields, see Character + + def __post_init__(self): + if self.source not in ("generated", "imported"): + raise ValueError(f"Clip.source must be 'generated' or 'imported', got {self.source!r}") + + +@dataclass +class Run: + """One contiguous stretch of `Document` text carrying (at most) one + `Clip.id` - the tagged-run-list replacement for offset-shifted `Clip` + ranges (Claude/PLAN_text_editor_redesign.md, TE5). Mirrors, at the + Qt-free data-model level, the `QTextCharFormat` custom property the real + `TranscriptEditor` widget applies to the equivalent stretch of its + `QTextDocument` - this class is what lets `kokoro_gui/daw/` stay + Qt-free (per this package's `__init__.py`) while still round-tripping + through `serialization.py`'s JSON shape and being usable headlessly (a + future CLI, or this module's own test suite). + + `clip_id=None` means "untagged" - ordinary narration nobody has assigned + a character to yet, exactly like today's "some text has no clip" state. + `kind` mirrors the owning `Clip.source` ("generated"/"imported") for a + tagged run, or is `None` for an untagged one; `"placeholder"` is reserved + for a future ASR-anchored import awaiting transcription (deliberately + unused for now - see the plan doc's "Open items", this pass only + reserves the marker, it doesn't build the import UX behind it). + """ + + text: str = "" + clip_id: Optional[str] = None + kind: Optional[str] = None + extra: dict = field(default_factory=dict) # unknown fields, see Character + + +@dataclass +class Document: + """The whole project's source of truth (Q15's closing principle). Owns + the canonical run list plus the clip/track/character metadata layered on + top of it. `text` is a computed property (the join of every run's text), + not a stored field - see the module docstring.""" + + runs: list = field(default_factory=list) + clips: list = field(default_factory=list) + tracks: list = field(default_factory=list) + characters: list = field(default_factory=list) + settings: dict = field(default_factory=dict) + # Runtime/session-only (item 4, "Undo/redo") - a `UndoStack` needs a + # reference to its owning `Document`, which a `field(default_factory=...)` + # can't capture (no access to `self` there), hence the `__post_init__` + # construction below instead. NEVER include this in + # `kokoro_gui/daw/serialization.py`'s `document_to_dict` (or any other + # persistence path) - undo history is not part of a saved project, it's + # this session's editing history only. + undo_stack: Optional[UndoStack] = field(default=None, init=False, repr=False) + # Runtime-only like `undo_stack`: `(text, clip, engine_version=None) -> + # segment key`, set by the app (kokoro_gui/qt/app.py's + # `_switch_document`) as a closure over the active backend and the + # project dir, since this daw layer has neither. `dirty_clips` hands it + # to `dirty.is_clip_dirty`; unset (tests, headless use) the check falls + # back to the name-only `compute_cache_key`. Never serialized. + segment_key_fn: Optional[Callable] = field(default=None, init=False, repr=False) + + def __post_init__(self): + self.undo_stack = UndoStack(self) + + @classmethod + def from_plain_text(cls, text: str = "", **kwargs) -> "Document": + """Convenience constructor for a fresh `Document` whose entire text + is one untagged run - the common case for a brand-new project, a + freshly-loaded plain-text import, or a test fixture that doesn't + care about tagging.""" + return cls(runs=[Run(text=text)] if text else [], **kwargs) + + def set_plain_text(self, text: str) -> None: + """Replaces the whole document with one untagged run, discarding + every existing run tag - a full reload/reset, NOT an ordinary edit + primitive (see `replace_text` for that).""" + self.runs = [Run(text=text)] if text else [] + + # -- text (derived) ------------------------------------------------------ + + @property + def text(self) -> str: + return "".join(run.text for run in self.runs) + + @text.setter + def text(self, value: str) -> None: + """Convenience alias for `set_plain_text` - a full reload/reset, NOT + an ordinary edit primitive (use `replace_text` for that). Kept as a + settable property (rather than getter-only) since a lot of call + sites - tests especially - reasonably expect `doc.text = "..."` to + keep working the way it always did before `text` became derived.""" + self.set_plain_text(value) + + def _iter_runs_with_offsets(self): + """Yields `(run, start, end)` for every run, in document order - + the shared walk every offset-deriving lookup below builds on.""" + pos = 0 + for run in self.runs: + end = pos + len(run.text) + yield run, pos, end + pos = end + + def _run_covering(self, position: int) -> Optional[Run]: + for run, start, end in self._iter_runs_with_offsets(): + if start <= position < end: + return run + return None + + # -- lookups ----------------------------------------------------------- + + def get_character(self, character_id: Optional[str]) -> Optional[Character]: + if character_id is None: + return None + return next((c for c in self.characters if c.id == character_id), None) + + def get_character_by_name(self, name: str) -> Optional[Character]: + """Case-insensitive, whitespace-stripped lookup by `Character.name` + - unlike `get_character`/`get_track`/`get_clip` above, this is a + name-based (not id-based) lookup, since a `[Speaker:FX]:` tag + (auto-split, item 7 of the DAW-for-text remaining-work roadmap) + names a character by its display name, not its id. Returns `None` + if no character's name matches, case-insensitively, ignoring + leading/trailing whitespace on both sides.""" + if name is None: + return None + target = name.strip().lower() + return next((c for c in self.characters if c.name.strip().lower() == target), None) + + def get_track(self, track_id: Optional[str]) -> Optional[Track]: + if track_id is None: + return None + return next((t for t in self.tracks if t.id == track_id), None) + + def get_clip(self, clip_id: str) -> Optional[Clip]: + return next((c for c in self.clips if c.id == clip_id), None) + + def clip_covering(self, position: int) -> Optional[Clip]: + """The `Clip` covering text offset `position`, if any (inclusive + start, exclusive end) - reads whichever run's tag covers that + position, rather than scanning a stored offset-range list.""" + run = self._run_covering(position) + return self.get_clip(run.clip_id) if run is not None else None + + def clip_extent(self, clip_id: str) -> Optional[tuple]: + """The `(start, end)` character-offset span a clip's tagged run(s) + currently occupy in `self.text`, or `None` if no run carries that + id. Computed on demand by walking `self.runs` - this is the + run-based replacement for reading `clip.start_offset`/`end_offset` + directly (timeline positioning, sub-range TTS replacement, etc. all + go through this now).""" + start = end = None + for run, r_start, r_end in self._iter_runs_with_offsets(): + if run.clip_id == clip_id: + if start is None: + start = r_start + end = r_end + return None if start is None else (start, end) + + # -- text/config ------------------------------------------------------- + + def clip_text(self, clip: Clip) -> str: + """The clip's current text - every run tagged with `clip.id`, + concatenated in document order.""" + return "".join(run.text for run in self.runs if run.clip_id == clip.id) + + def effective_config_for_clip(self, clip: Clip) -> dict: + """The clip's character preset merged with its own overrides (Q7: + editing a character retroactively affects every clip using it, + unless that clip has an explicit override) - reuses the same + `filter_allowed_keys` whitelist `presets.py` already applies to a + loaded preset, so a clip override can't smuggle in a disallowed key + either.""" + character = self.get_character(clip.character_id) + config = dict(character.preset_data) if character else {} + config.update(filter_allowed_keys(clip.overrides, ALLOWED_PRESET_KEYS)) + return config + + def dirty_clips(self) -> list: + """Every `Clip` that needs (re)generation - see `dirty.is_clip_dirty` + for what "dirty" means. Imported lazily to avoid a module-level + import cycle (dirty.py has no need to import models.py, but keeping + the dependency one-directional and local here is simplest).""" + from kokoro_gui.daw.dirty import is_clip_dirty + + return [ + clip + for clip in self.clips + if is_clip_dirty(clip, self.clip_text(clip), self.effective_config_for_clip(clip), + key_fn=self.segment_key_fn) + ] + + # -- run-list maintenance (private) ------------------------------------- + + def _split_at(self, offset: int) -> None: + """Splits whichever run straddles `offset` into two runs at that + boundary, so later code can retag/replace an exact `[start, end)` + span without disturbing text on either side of it. A no-op if + `offset` already falls on a run boundary (including the document's + own start/end).""" + if offset <= 0 or offset >= len(self.text): + return + pos = 0 + for i, run in enumerate(self.runs): + end = pos + len(run.text) + if pos < offset < end: + cut = offset - pos + self.runs[i:i + 1] = [ + Run(text=run.text[:cut], clip_id=run.clip_id, kind=run.kind), + Run(text=run.text[cut:], clip_id=run.clip_id, kind=run.kind), + ] + return + pos = end + + def _normalize_runs(self) -> None: + """Drops zero-length runs and merges adjacent runs sharing the same + `clip_id`/`kind` - the run-list equivalent of Qt's own "typing + inside a run just extends it" merge behavior, kept true here too so + two operations that happen to retag neighboring spans identically + don't leave a meaningless split between them.""" + merged: list = [] + for run in self.runs: + if not run.text: + continue + if merged and merged[-1].clip_id == run.clip_id and merged[-1].kind == run.kind: + merged[-1] = Run(text=merged[-1].text + run.text, clip_id=run.clip_id, kind=run.kind) + else: + merged.append(run) + self.runs = merged + + def _retag_range(self, start: int, end: int, clip_id: Optional[str], kind: Optional[str]) -> None: + """Replaces whatever runs currently occupy `[start, end)` with a + single run of that same text, tagged `clip_id`/`kind` - the shared + "retag an exact span" primitive `assign_character_to_range` below + applies once per clip it touches (the new clip's span, plus one per + leftover fragment). Never changes `len(self.text)`.""" + self._split_at(start) + self._split_at(end) + + new_runs: list = [] + merged_text_parts: list = [] + inserted_at: Optional[int] = None + pos = 0 + for run in self.runs: + run_end = pos + len(run.text) + if run_end <= start or pos >= end: + new_runs.append(run) + else: + merged_text_parts.append(run.text) + if inserted_at is None: + inserted_at = len(new_runs) + new_runs.append(None) + pos = run_end + + new_runs[inserted_at] = Run(text="".join(merged_text_parts), clip_id=clip_id, kind=kind) + self.runs = new_runs + self._normalize_runs() + + # -- UI-driven authoring (Q20): Characters menu / paste-splitting ------ + + def assign_character_to_range(self, start: int, end: int, character_id: Optional[str]) -> Clip: + """Assigns `character_id` to `self.text[start:end]`, creating a new + `Clip` for that exact range and splitting off "leftover" clips for + whatever the range partially overlapped - the shared split-or-create + primitive behind the transcript panel's Characters menu and + paste-splitting, and (later) the timeline's sub-range TTS replacement + and auto-split features. + + A clip fully inside `[start, end)` is simply removed (no leftover). + A clip only partially overlapping keeps a leftover fragment for the + portion outside `[start, end)`, carrying its original character/ + track/overrides/fx_override - but as a brand-new `Clip` (fresh id, + no segments), since a split invalidates whatever was cached for the + now-different range. Even an exact range-for-range reassignment goes + through remove-then-recreate: identity is independent of content, + the same rule `replace_text`'s fully-consumed-clip removal already + establishes. + + No manual dirty-marking is needed - every clip this method touches + ends up with no `segments`, which `dirty.is_clip_dirty` already + treats as dirty. + """ + if end <= start: + raise ValueError(f"assign_character_to_range requires end > start, got start={start}, end={end}") + text_len = len(self.text) + if start < 0 or end > text_len: + raise ValueError( + f"assign_character_to_range requires [start, end) within [0, {text_len}), " + f"got start={start}, end={end}" + ) + + track_id = next((t.id for t in self.tracks if t.character_id == character_id), None) + + overlapping_ids = set() + for run, r_start, r_end in self._iter_runs_with_offsets(): + if run.clip_id is not None and r_start < end and r_end > start: + overlapping_ids.add(run.clip_id) + + leftover_ranges = [] # (start, end, old_clip) + for clip_id in overlapping_ids: + old_clip = self.get_clip(clip_id) + if old_clip is None: + continue + o_start, o_end = self.clip_extent(clip_id) + if o_start < start: + leftover_ranges.append((o_start, start, old_clip)) + if o_end > end: + leftover_ranges.append((end, o_end, old_clip)) + + leftover_clips = [ + Clip( + character_id=old_clip.character_id, track_id=old_clip.track_id, + overrides=dict(old_clip.overrides), fx_override=old_clip.fx_override, + source=old_clip.source, original_audio_path=old_clip.original_audio_path, + ) + for (_l_start, _l_end, old_clip) in leftover_ranges + ] + + new_clip = Clip(character_id=character_id, track_id=track_id) + + self.clips = [c for c in self.clips if c.id not in overlapping_ids] + self.clips.extend(leftover_clips) + self.clips.append(new_clip) + + # None of these _retag_range calls change len(self.text), so it's + # safe to apply them in any order using offsets all computed above, + # against the pre-edit run layout. + self._retag_range(start, end, new_clip.id, new_clip.source) + for (l_start, l_end, _old_clip), leftover_clip in zip(leftover_ranges, leftover_clips): + self._retag_range(l_start, l_end, leftover_clip.id, leftover_clip.source) + + return new_clip + + # -- plain text edits (typing, paste, programmatic replace) ------------- + + def replace_text(self, position: int, chars_removed: int, chars_added: int, new_text: str) -> list: + """Applies one `QTextDocument.contentsChange`-shaped edit + (position/charsRemoved/charsAdded, plus the resulting full text - + Qt's signal doesn't carry the inserted characters themselves, so the + caller passes `editor.toPlainText()` after the change) directly + against the run list - the run-based replacement for the retired + `apply_text_change`/offset-shift mechanism (see the module + docstring's "core inversion"). + + A clip whose entire extent falls inside `[position, position + + chars_removed)` is fully consumed and removed outright (Q18: a + later, textually-identical retype creates a brand-new `Clip` with a + fresh id even though its audio may still cache-hit). A clip that + only partially overlaps the edited range keeps its identity - the + portion of its run(s) outside the edited range is untouched by this + splice, so it simply survives, shrunk or extended in place. + + The newly inserted text inherits the tag of whatever run ends + exactly at `position` (i.e. the text immediately to the edit's + left) - ordinary "typing extends the current run" behavior, same as + a real rich-text editor's cursor format inheritance. Typing at the + very start of the document, or right after a clip that this same + edit fully consumed, leaves the inserted text untagged. + + Returns the list of `Clip`s removed by this edit, for callers that + need to react (e.g. dropping them from a track view). + """ + removed_end = position + chars_removed + + overlapping_ids = set() + for run, r_start, r_end in self._iter_runs_with_offsets(): + if run.clip_id is not None and r_start < removed_end and r_end > position: + overlapping_ids.add(run.clip_id) + + fully_consumed_ids = { + clip_id for clip_id in overlapping_ids + if chars_removed > 0 + for (o_start, o_end) in [self.clip_extent(clip_id)] + if position <= o_start and o_end <= removed_end + } + + removed_clips = [self.get_clip(clip_id) for clip_id in fully_consumed_ids] + self.clips = [c for c in self.clips if c.id not in fully_consumed_ids] + + inherited = self._run_covering(position - 1) if position > 0 else None + inherited_clip_id = inherited.clip_id if inherited is not None else None + inherited_kind = inherited.kind if inherited is not None else None + if inherited_clip_id in fully_consumed_ids: + inherited_clip_id = None + inherited_kind = None + + inserted_text = new_text[position:position + chars_added] + + self._split_at(position) + self._split_at(removed_end) + + new_run = Run(text=inserted_text, clip_id=inherited_clip_id, kind=inherited_kind) + new_runs: list = [] + inserted = False + pos = 0 + for run in self.runs: + run_end = pos + len(run.text) + if pos >= position and run_end <= removed_end and pos < removed_end: + if not inserted: + new_runs.append(new_run) + inserted = True + pos = run_end + continue + if not inserted and pos >= position: + new_runs.append(new_run) + inserted = True + new_runs.append(run) + pos = run_end + if not inserted: + new_runs.append(new_run) + + self.runs = new_runs + self._normalize_runs() + return removed_clips diff --git a/kokoro_gui/daw/serialization.py b/kokoro_gui/daw/serialization.py new file mode 100644 index 0000000..21a1a6f --- /dev/null +++ b/kokoro_gui/daw/serialization.py @@ -0,0 +1,219 @@ +"""Save/load a `Document` as `document.json` - a new project file, kept +separate from `config_qt.json`'s flat app-settings shape rather than folded +into it, but following the same zero-ceremony "one implicit session, +autoloaded/autosaved" model (no File>Open/Save-As UX added here; that's an +open product question for a later pass, per +Claude/PLAN_daw_ui_ux_redesign.md). + +Since Claude/PLAN_text_editor_redesign.md, the on-disk shape is a run list +(`{"runs": [{"text": ..., "clip_id": ..., "kind": ...}, ...], "clips": [...], +...}`) rather than a flat `"text"` string plus offset-ranged clips - this +*is* the "JSON tagging" the redesign asked for, produced by walking +`Document.runs` directly rather than hand-serializing a parallel +offset-tracked object. `document_from_dict` still reads the old +`{"text": ..., "clips": [{"start_offset": ..., "end_offset": ..., ...}]}` +shape for any `document.json` written before this rework - see +`_runs_from_legacy_offsets` below. + +Unknown fields round-trip. Every model object has an `extra` dict: +`document_from_dict` splits each object's dict into the fields the running +version knows and the rest, and `document_to_dict` merges the rest back at +the same level, so a `document.json` written by a newer KokoroGUI survives a +load and save through an older one with its extra fields intact (the `.tbaw` +plan's version policy depends on this). `Character.preset_data` is filtered +through `ALLOWED_PRESET_KEYS` on the way in, and the stripped keys ride in +`extra["preset_data"]`. +""" +import dataclasses +import json +import os + +from kokoro_gui.daw.models import Character, Clip, Document, Run, Segment, Track +from kokoro_gui.engine.presets import ALLOWED_PRESET_KEYS, filter_allowed_keys + + +def _known_fields(cls) -> set: + return {f.name for f in dataclasses.fields(cls) if f.init} + + +def _split_unknown(cls, data: dict) -> tuple: + """`(known, extra)`: `data`'s keys the dataclass `cls` accepts, and the + rest. An `extra` key already in `data` (a file written by this version) + is folded into the returned extra rather than nested twice.""" + known_names = _known_fields(cls) - {"extra"} + known = {} + extra = {} + for key, value in data.items(): + if key == "extra" and isinstance(value, dict): + extra.update(value) + elif key in known_names: + known[key] = value + else: + extra[key] = value + return known, extra + + +def _to_dict(obj) -> dict: + """`dataclasses.asdict` minus `extra`, whose contents are merged back at + the same level. A known field always wins over a stale `extra` entry of + the same name.""" + data = dataclasses.asdict(obj) + extra = data.pop("extra", {}) or {} + return {**extra, **data} + + +def _character_to_dict(character: Character) -> dict: + data = _to_dict(character) + stripped = (character.extra or {}).get("preset_data") + if isinstance(stripped, dict): + data.pop("preset_data", None) + data["preset_data"] = {**stripped, **character.preset_data} + return data + + +def document_to_dict(doc: Document) -> dict: + """Plain-JSON-serializable shape for `doc`. + + Deliberately built field-by-field rather than via a blanket + `dataclasses.asdict(doc)` - this is what keeps `doc.undo_stack` (item 4, + "Undo/redo") and `doc.segment_key_fn` out of the saved file for free: + both are runtime/session-only and neither is JSON-serializable.""" + clips = [] + for clip in doc.clips: + data = _to_dict(clip) + data["segments"] = [_to_dict(s) for s in clip.segments] + clips.append(data) + return { + "runs": [_to_dict(r) for r in doc.runs], + "clips": clips, + "tracks": [_to_dict(t) for t in doc.tracks], + "characters": [_character_to_dict(c) for c in doc.characters], + "settings": dict(doc.settings), + } + + +def rewrite_audio_paths(data: dict, fn) -> dict: + """Applies `fn(path) -> path` to every `Segment.audio_path` and + `Clip.original_audio_path` in a `document_to_dict`-shaped dict, in + place, skipping `None`. Used in both directions by the `.tbaw` bundle + (absolute inside the project dir <-> bundle-relative).""" + for clip in data.get("clips", []): + if clip.get("original_audio_path"): + clip["original_audio_path"] = fn(clip["original_audio_path"]) + for segment in clip.get("segments", []): + if segment.get("audio_path"): + segment["audio_path"] = fn(segment["audio_path"]) + return data + + +def _runs_from_legacy_offsets(text: str, clips: list, legacy_offsets: dict) -> list: + """Migration path for a `document.json` written before the tagged-run + rework: walks `clips` sorted by their old `start_offset`, emitting one + run per clip plus untagged runs for whatever text fell outside every + clip's old range - the one-time offsets-to-runs conversion + Claude/PLAN_text_editor_redesign.md's "Migration path" section calls + for, same spirit as `migration.py`'s existing presets-to-Character + bootstrap.""" + ranges = sorted( + ( + (start, end, clip.id, clip.source) + for clip in clips + if clip.id in legacy_offsets + for start, end in [legacy_offsets[clip.id]] + ), + key=lambda r: r[0], + ) + runs = [] + cursor = 0 + for start, end, clip_id, kind in ranges: + if start > cursor: + runs.append(Run(text=text[cursor:start])) + runs.append(Run(text=text[start:end], clip_id=clip_id, kind=kind)) + cursor = max(cursor, end) + if cursor < len(text): + runs.append(Run(text=text[cursor:])) + return runs + + +def document_from_dict(data: dict) -> Document: + """Inverse of `document_to_dict`. Tolerant of missing keys (an older or + hand-edited `document.json`) the same way the rest of this codebase reads + config/preset dicts with `.get(...)` defaults rather than requiring every + key. Reads a pre-rework, offset-based `document.json` transparently via + `_runs_from_legacy_offsets` when the file has no `"runs"` key at all.""" + clips = [] + legacy_offsets = {} + for clip_data in data.get("clips", []): + clip_data = dict(clip_data) + # A saved segment without "raw" predates read-time FX: its file has + # FX baked in, so it must not be post-processed again (see + # Segment's docstring; dirty.is_clip_dirty regenerates it). + segments = [] + for seg in clip_data.pop("segments", []): + known, extra = _split_unknown(Segment, {"raw": False, **seg}) + segments.append(Segment(extra=extra, **known)) + start_offset = clip_data.pop("start_offset", None) + end_offset = clip_data.pop("end_offset", None) + known, extra = _split_unknown(Clip, clip_data) + clip = Clip(segments=segments, extra=extra, **known) + clips.append(clip) + if start_offset is not None and end_offset is not None: + legacy_offsets[clip.id] = (start_offset, end_offset) + + tracks = [] + for t in data.get("tracks", []): + known, extra = _split_unknown(Track, t) + tracks.append(Track(extra=extra, **known)) + + characters = [] + for c in data.get("characters", []): + known, extra = _split_unknown(Character, c) + preset_data = known.get("preset_data") or {} + if isinstance(preset_data, dict): + stripped = {k: v for k, v in preset_data.items() if k not in ALLOWED_PRESET_KEYS} + known["preset_data"] = filter_allowed_keys(preset_data, ALLOWED_PRESET_KEYS) + if stripped: + extra["preset_data"] = stripped + characters.append(Character(extra=extra, **known)) + + if "runs" in data: + runs = [] + for r in data["runs"]: + known, extra = _split_unknown(Run, r) + runs.append(Run(extra=extra, **known)) + else: + runs = _runs_from_legacy_offsets(data.get("text", ""), clips, legacy_offsets) + + return Document( + runs=runs, + clips=clips, + tracks=tracks, + characters=characters, + settings=dict(data.get("settings", {})), + ) + + +def save_document(doc: Document, path: str) -> None: + """Writes `doc` to `path` as JSON. Creates the parent directory if + needed, matching the tolerant-write style the rest of the app's + settings/preset save paths use.""" + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(document_to_dict(doc), f, indent=2) + + +def load_document(path: str): + """Reads `path` back into a `Document`, or returns `None` if the file + doesn't exist or fails to parse - callers should treat `None` as "start a + fresh Document" the same way a missing/corrupt `config_qt.json` falls + back to defaults elsewhere in this app.""" + if not os.path.exists(path): + return None + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return None + return document_from_dict(data) diff --git a/kokoro_gui/daw/undo.py b/kokoro_gui/daw/undo.py new file mode 100644 index 0000000..960e98c --- /dev/null +++ b/kokoro_gui/daw/undo.py @@ -0,0 +1,357 @@ +"""Undo/redo (item 4 of the DAW-for-text redesign's remaining-work roadmap): +a plain-Python `Command`/`UndoStack` pair, deliberately NOT named +`QUndoCommand`/`QUndoStack` and NOT importing anything from `PySide6`. + +`kokoro_gui/daw/` is a verified zero-Qt-imports package today (see this +package's `__init__.py`) - `Document` stays constructible/testable without a +`QApplication`, and this module preserves that. This is a deliberate, +documented deviation from the more obvious "just use QUndoStack" precedent: +the one-stack-per-`Document` intent is kept, the Qt dependency is not. + +Per Claude/PLAN_text_editor_redesign.md's undo-granularity grill: ordinary +interactive typing in the real GUI does NOT go through this stack at all - +it's recorded on the live `QTextDocument`'s own native undo (Qt already +coalesces keystrokes like a word processor, for free), coordinated with this +stack by `kokoro_gui.qt.transcript_editor` so Ctrl+Z pops whichever of the +two histories has the more recent action. This stack still handles every +*non-typing* document operation (character/FX assignment, clip moves, FX +overrides) - see docs/timeline_dock.py's use of `TextEditCommand` for the one +still-custom-stack text mutation, the sub-range TTS replace (a +programmatic, non-interactive text replacement triggered by a button, not +typing). + +No dirty/clean-flag tracking here - the app already autosaves on every +change (see `kokoro_gui.qt.settings`), so adding one would be new, unrequested +complexity. +""" +from __future__ import annotations + +import copy + + +class Command: + """Base class for one undoable action against a `Document`. Concrete + commands implement `do`/`undo`; the base versions raise so a stub + command (see `MoveClipCommand` etc. below) fails loudly if ever pushed + before it's actually implemented, rather than silently no-op'ing.""" + + def do(self, document) -> None: + raise NotImplementedError + + def undo(self, document) -> None: + raise NotImplementedError + + +class UndoStack: + """One stack per `Document`, constructed with a reference to the + `Document` it operates on so call sites never need to thread `document` + through every `push`/`undo`/`redo` call themselves.""" + + def __init__(self, document): + self._document = document + self._undo: list = [] + self._redo: list = [] + # Optional zero-arg hook, set from the Qt layer (see + # kokoro_gui.qt.undo_coordinator.UndoCoordinator) so it can log this + # stack's pushes into the same interleaved order log as the live + # QTextDocument's native undo, without this module importing + # anything Qt-related itself. `None` (the default) means nobody's + # listening - `push`/`clear_redo` stay plain no-ops toward it. + self.on_push = None + + def push(self, command: Command) -> None: + """Runs `command.do(document)`, records it as the most recent undoable + action, and invalidates any redo history - a new action after an + undo makes the undone-and-now-abandoned branch unreachable, the same + behavior every standard undo stack has.""" + command.do(self._document) + self._undo.append(command) + self._redo.clear() + if self.on_push is not None: + self.on_push() + + def clear_redo(self) -> None: + """Discards redo history without touching undo - called by + `UndoCoordinator` when the live QTextDocument's native stack records + a fresh (unrelated) edit, so a stale "redo the character + assignment I just undid" doesn't survive an intervening edit on the + other stack (ordinary "any new action clears redo" semantics, + just applied across two independent stacks instead of one).""" + self._redo.clear() + + def undo(self) -> None: + """No-op if there's nothing to undo.""" + if not self._undo: + return + command = self._undo.pop() + command.undo(self._document) + self._redo.append(command) + + def redo(self) -> None: + """No-op if there's nothing to redo.""" + if not self._redo: + return + command = self._redo.pop() + command.do(self._document) + self._undo.append(command) + + def can_undo(self) -> bool: + return bool(self._undo) + + def can_redo(self) -> bool: + return bool(self._redo) + + +class AssignCharacterCommand(Command): + """Wraps `Document.assign_character_to_range` (the Characters-menu / + gutter-dropdown / paste-splitting primitive). Rather than trying to + reconstruct exactly which runs/clips a split touched, `do()` snapshots + (deep-copies) the WHOLE `document.runs`/`document.clips` lists BEFORE + calling it, and `undo()` restores both verbatim - `Run`/`Clip` are small + plain-data dataclasses, so a full deep copy is cheap, and it sidesteps + the lossy-reconstruction trap the retired offset-based version had to + work around with a partial-snapshot-plus-reverse-replay (see + `TextEditCommand` below for the same reasoning applied to text edits). + + `redo()` re-runs `do()` from the (now-restored) pre-split state, so it + re-derives a fresh snapshot and re-applies the exact same split each + time - correct across any number of undo/redo cycles. + """ + + def __init__(self, start: int, end: int, character_id): + self.start = start + self.end = end + self.character_id = character_id + self._pre_runs: "list | None" = None + self._pre_clips: "list | None" = None + self.new_clip_id: "str | None" = None + + def do(self, document) -> None: + self._pre_runs = copy.deepcopy(document.runs) + self._pre_clips = copy.deepcopy(document.clips) + new_clip = document.assign_character_to_range(self.start, self.end, self.character_id) + self.new_clip_id = new_clip.id + + def undo(self, document) -> None: + document.runs = copy.deepcopy(self._pre_runs) + document.clips = copy.deepcopy(self._pre_clips) + + +class TextEditCommand(Command): + """Wraps `Document.replace_text` for one text edit - used only for + non-interactive, custom-stack text mutations (the sub-range TTS replace + button; see docs/timeline_dock.py), NOT for ordinary typing in the + transcript editor, which now rides Qt's own native `QTextDocument` undo + instead (see this module's docstring). + + Same snapshot-the-whole-run-list strategy as `AssignCharacterCommand`, + for the same reason: once an edit fully consumes a clip, there's no + longer enough information left in `position`/`chars_removed`/ + `chars_added` alone to know which of the surviving text's *other* runs + that clip's characters used to belong to, so a naive "replay the edit in + reverse" can mis-tag the restored text. Snapshotting avoids the problem + entirely instead of solving it. + """ + + def __init__(self, position: int, chars_removed: int, chars_added: int, new_text: str): + self.position = position + self.chars_removed = chars_removed + self.chars_added = chars_added + self.new_text = new_text + self._pre_runs: "list | None" = None + self._pre_clips: "list | None" = None + + def do(self, document) -> None: + self._pre_runs = copy.deepcopy(document.runs) + self._pre_clips = copy.deepcopy(document.clips) + document.replace_text(self.position, self.chars_removed, self.chars_added, self.new_text) + + def undo(self, document) -> None: + document.runs = copy.deepcopy(self._pre_runs) + document.clips = copy.deepcopy(self._pre_clips) + + +class MoveClipCommand(Command): + """Item 8 ("Drag-to-reassign a clip to a different track"): the "just + move it visually" half of Q9's reassign-vs-move prompt - relocates a + clip to a different track's lane while leaving every other field + (`character_id` included) untouched. `do()` snapshots the clip's + current `track_id` before overwriting it, so `undo()` can restore it + verbatim. Untouched by the run-list rework - it only ever mutates a + `Clip`'s own fields, never `document.runs`.""" + + def __init__(self, clip_id: str, new_track_id: str): + self.clip_id = clip_id + self.new_track_id = new_track_id + self._previous_track_id: "str | None" = None + + def do(self, document) -> None: + clip = document.get_clip(self.clip_id) + if clip is None: + return + self._previous_track_id = clip.track_id + clip.track_id = self.new_track_id + + def undo(self, document) -> None: + clip = document.get_clip(self.clip_id) + if clip is None: + return + clip.track_id = self._previous_track_id + + +class ReassignTrackCommand(Command): + """Item 8's "reassign" half of Q9's prompt: moves a clip to a different + track's lane AND reassigns its `character_id` to match that track's + character - unlike `MoveClipCommand`, which only ever touches + `track_id`. `do()` snapshots both `track_id` and `character_id` before + overwriting them, so `undo()` restores both verbatim. Untouched by the + run-list rework - it only ever mutates a `Clip`'s own fields, never + `document.runs`.""" + + def __init__(self, clip_id: str, new_track_id: str, new_character_id): + self.clip_id = clip_id + self.new_track_id = new_track_id + self.new_character_id = new_character_id + self._previous_track_id: "str | None" = None + self._previous_character_id = None + + def do(self, document) -> None: + clip = document.get_clip(self.clip_id) + if clip is None: + return + self._previous_track_id = clip.track_id + self._previous_character_id = clip.character_id + clip.track_id = self.new_track_id + clip.character_id = self.new_character_id + + def undo(self, document) -> None: + clip = document.get_clip(self.clip_id) + if clip is None: + return + clip.track_id = self._previous_track_id + clip.character_id = self._previous_character_id + + +class SetClipFxCommand(Command): + """Item 5 ("Per-clip FX button"): sets/clears one `Clip`'s + `fx_override` - a resolved FX-values dict (the `ALLOWED_FX_PRESET_KEYS` + shape), not a preset name, so an override survives the source preset + later being renamed or deleted. `fx_values=None` clears the override + back to "no clip-level FX, defer to the character's fx_preset." + + `preset_name` (UI shell pass) is recorded alongside, in + `clip.overrides["fx_preset"]`, so the gutter and the transcript header + can name the preset the values came from; the Settings tab's clip-mode + FX combo writes the same key. Clearing (`fx_values=None`) drops the + name too. + + Deep-copies on the way in and out so a caller mutating its own dict + after construction - or a later edit mutating `clip.fx_override` in + place - can never alias back into this command's undo history. + """ + + def __init__(self, clip_id: str, fx_values, preset_name=None): + self.clip_id = clip_id + self.fx_values = copy.deepcopy(fx_values) if fx_values else fx_values + self.preset_name = preset_name + self._previous = None + self._previous_name = None + self._had_name = False + + def do(self, document) -> None: + clip = document.get_clip(self.clip_id) + if clip is None: + return + self._previous = copy.deepcopy(clip.fx_override) if clip.fx_override else clip.fx_override + self._had_name = "fx_preset" in clip.overrides + self._previous_name = clip.overrides.get("fx_preset") + clip.fx_override = copy.deepcopy(self.fx_values) if self.fx_values else self.fx_values + if self.fx_values is None: + clip.overrides.pop("fx_preset", None) + elif self.preset_name: + clip.overrides["fx_preset"] = self.preset_name + + def undo(self, document) -> None: + clip = document.get_clip(self.clip_id) + if clip is None: + return + clip.fx_override = copy.deepcopy(self._previous) if self._previous else self._previous + if self._had_name: + clip.overrides["fx_preset"] = self._previous_name + else: + clip.overrides.pop("fx_preset", None) + + +class SetClipTimestampCommand(Command): + """UI9: a horizontal drag on the timeline pins a clip to an explicit + start time (`Clip.timeline_timestamp`, seconds). `None` unpins it so + `compute_arrangement` places it after its text-order predecessor + again.""" + + def __init__(self, clip_id: str, timestamp): + self.clip_id = clip_id + self.timestamp = timestamp + self._previous = None + + def do(self, document) -> None: + clip = document.get_clip(self.clip_id) + if clip is None: + return + self._previous = clip.timeline_timestamp + clip.timeline_timestamp = self.timestamp + + def undo(self, document) -> None: + clip = document.get_clip(self.clip_id) + if clip is None: + return + clip.timeline_timestamp = self._previous + + +class MoveClipBeforeCommand(Command): + """UI9 / grill Q13: dragging a clip to before another clip on the + timeline also moves its text to just before that clip's text. Moves + every run tagged `clip_id` (in order) to immediately before the first + run tagged `before_clip_id`. Same whole-run-list snapshot strategy as + `AssignCharacterCommand`. Untagged text between the moved clip's runs + stays where it was; only the tagged runs travel. + + Also pins the moved clip's `timeline_timestamp` to `timestamp` when one + is given (the drop position), so the drag's visual result and the text + reorder land in one undoable step.""" + + def __init__(self, clip_id: str, before_clip_id: str, timestamp=None): + self.clip_id = clip_id + self.before_clip_id = before_clip_id + self.timestamp = timestamp + self._pre_runs = None + self._pre_clips = None + self._previous_timestamp = None + + def do(self, document) -> None: + self._pre_runs = copy.deepcopy(document.runs) + self._pre_clips = copy.deepcopy(document.clips) + clip = document.get_clip(self.clip_id) + if clip is None or self.clip_id == self.before_clip_id: + return + moving = [r for r in document.runs if r.clip_id == self.clip_id] + if not moving: + return + remaining = [r for r in document.runs if r.clip_id != self.clip_id] + insert_at = next((i for i, r in enumerate(remaining) if r.clip_id == self.before_clip_id), None) + if insert_at is None: + return + document.runs = remaining[:insert_at] + moving + remaining[insert_at:] + document._normalize_runs() + self._previous_timestamp = clip.timeline_timestamp + if self.timestamp is not None: + clip.timeline_timestamp = self.timestamp + + def undo(self, document) -> None: + document.runs = copy.deepcopy(self._pre_runs) + document.clips = copy.deepcopy(self._pre_clips) + + +# The split-or-create primitive item 7 ("Auto-split on generation") and +# item 9 ("Sub-range TTS replacement") will reuse is exactly +# `assign_character_to_range` - a plain alias, not a new class. +SplitClipCommand = AssignCharacterCommand diff --git a/kokoro_gui/engine/__init__.py b/kokoro_gui/engine/__init__.py new file mode 100644 index 0000000..1f85bcb --- /dev/null +++ b/kokoro_gui/engine/__init__.py @@ -0,0 +1,21 @@ +from .audio_fx import AudioFXMixin +from .caching import CachingMixin +from .conversion import ConversionMixin +from .jit import JITMixin +from .lexicon import LexiconMixin +from .presets import PresetsMixin +from .srt import SrtMixin +from .text_extraction import TextExtractionMixin +from .voices import VoiceMixingMixin + +__all__ = [ + "AudioFXMixin", + "CachingMixin", + "ConversionMixin", + "JITMixin", + "LexiconMixin", + "PresetsMixin", + "SrtMixin", + "TextExtractionMixin", + "VoiceMixingMixin", +] diff --git a/kokoro_gui/engine/asr.py b/kokoro_gui/engine/asr.py new file mode 100644 index 0000000..73d73aa --- /dev/null +++ b/kokoro_gui/engine/asr.py @@ -0,0 +1,387 @@ +"""Auto-transcription helpers for reference audio, used by the Voice +Reference dock (kokoro_gui/qt/docks/voice_clone_dock.py) to pre-fill an +editable transcript for a WAV a user is about to use as an Audio8-TTS voice +reference. + +Two engines are registered in `ASR_ENGINES`, selectable from the dock's +engine picker: + +- `"audio8"` - https://huggingface.co/Audio8/Audio8-ASR-0.1B. Higher quality, + needs `transformers`/`torch`, downloads multi-GB weights from Hugging Face + on first use, and is CC-BY-NC-4.0 (non-commercial). +- `"vosk"` - https://alphacephei.com/vosk. Fully offline and Apache-2.0 + (commercial-friendly), but needs a model directory downloaded and unzipped + by hand from https://alphacephei.com/vosk/models (there's no pip-installable + weights the way `transformers.from_pretrained` fetches Audio8's) and is + generally lower quality, especially on non-English audio. The model folder + lives in `VOSK_MODEL_PATH`, in a `.env` file at the project root (see + `.env.example`) rather than in `config_qt.json` - unlike the engine choice + itself, it's treated as deployment config, not a per-session GUI + preference. The Voice Reference dock can still edit it though: + `set_vosk_model_path` writes the change straight into `.env` (creating the + file if needed) via `dotenv.set_key`, rather than the dock hand-rolling its + own settings round-trip for one value. Whatever WAV format the reference + audio is in, it's converted to the 16-bit mono PCM Vosk requires before + recognition runs (see `_ensure_pcm16_mono` below) - the caller never has to + pre-convert it. + +Audio8's zero-shot voice cloning needs a transcript of the reference audio, +not just the audio itself - getting that by hand is tedious, so either engine +here is a starting point the user reviews/corrects, not a ground-truth +oracle. + +Both engines are lazy-imported (only inside their `_get_*_model()` helpers, +not at this module's top level) so importing this module - which happens +whenever the Voice Reference dock is built - never triggers a model +load/download by itself; only calling `transcribe_wav` does. Note +`transformers` is already an indirect hard dependency of this app (the +`kokoro` package imports it internally), so deferring *its* import isn't +about avoiding the import itself - what's actually deferred is +`AutoModel.from_pretrained(...)`/`AutoProcessor.from_pretrained(...)`, i.e. +the network fetch and weights landing in memory. Audio8 loads with +`trust_remote_code=True`, which executes Python code shipped in the model's +HF repo the first time it's loaded - inherent to how this model is +distributed, not something this module can avoid while still using it. +""" +from __future__ import annotations + +import json +import os +import sys +import tempfile +import threading +import wave +from dataclasses import dataclass + +import numpy as np +import soundfile as sf +from dotenv import find_dotenv, load_dotenv, set_key + +AUDIO8_MODEL_ID = "Audio8/Audio8-ASR-0.1B" +# Kept as an alias - some external callers/notes may still reference the old +# name from before this module supported more than one engine. +ASR_MODEL_ID = AUDIO8_MODEL_ID + +VOSK_MODEL_PATH_ENV_KEY = "VOSK_MODEL_PATH" + + +def _dotenv_path() -> str: + """Resolves the `.env` file both `_load_dotenv`/`set_vosk_model_path` + act on: whichever one `find_dotenv(usecwd=True)` locates walking up from + the current working directory, or - since that returns "" when none + exists yet - a new `.env` at the cwd (the project root, by this + codebase's convention), so saving from the GUI works on a first run with + no `.env` file at all.""" + return find_dotenv(usecwd=True) or os.path.join(os.getcwd(), ".env") + + +def _load_dotenv(override: bool = False) -> None: + """Populates `os.environ` from a `.env` file at (or above) the current + working directory, if one exists - a no-op otherwise. Safe to call at + import time: it's a local file read, not a network call, and with + `override=False` (the default) never overwrites a variable the + environment already set - a real env var still wins over a stale `.env` + entry.""" + load_dotenv(_dotenv_path(), override=override) + + +_load_dotenv() + + +def get_vosk_model_path() -> str: + """Reads the Vosk model folder from `VOSK_MODEL_PATH` - not a GUI + setting saved in `config_qt.json`, see this module's docstring - + defaulting to "" (unconfigured) if unset.""" + return os.environ.get(VOSK_MODEL_PATH_ENV_KEY, "").strip() + + +def set_vosk_model_path(path: str) -> None: + """Writes `VOSK_MODEL_PATH` into `.env` (`dotenv.set_key` creates the + file if it doesn't exist yet and rewrites just that one line, leaving + any other keys already in it alone) and updates `os.environ` so the + running process picks up the change immediately - no reload needed. + Wired to the Voice Reference dock's "Save" button, the write-side + counterpart to `reload_vosk_model_path`'s read-side (file -> process).""" + path = path.strip() + set_key(_dotenv_path(), VOSK_MODEL_PATH_ENV_KEY, path) + os.environ[VOSK_MODEL_PATH_ENV_KEY] = path + + +def reload_vosk_model_path() -> str: + """Re-reads `.env` into `os.environ`, picking up an edit made to it while + the app is already running (plain `load_dotenv()` leaves already-set + variables alone, so this passes `override=True`), and returns the + resulting path. Wired to the Voice Reference dock's "Reload" button, the + read-side counterpart to `set_vosk_model_path`.""" + _load_dotenv(override=True) + return get_vosk_model_path() + + +@dataclass(frozen=True) +class AsrEngineInfo: + id: str + display_name: str + description: str + + +ASR_ENGINES = [ + AsrEngineInfo( + "audio8", + "Audio8-ASR-0.1B (online, higher quality)", + f"Downloads {AUDIO8_MODEL_ID} from Hugging Face on first use. " + "CC-BY-NC-4.0 - non-commercial use only.", + ), + AsrEngineInfo( + "vosk", + "Vosk (offline)", + "Fully offline once a model is downloaded from " + f"https://alphacephei.com/vosk/models and its folder is set as " + f"{VOSK_MODEL_PATH_ENV_KEY} in a .env file. Apache-2.0.", + ), +] +DEFAULT_ASR_ENGINE = ASR_ENGINES[0].id + + +def get_asr_engine(engine_id: str) -> AsrEngineInfo: + for engine in ASR_ENGINES: + if engine.id == engine_id: + return engine + raise ValueError(f"Unknown ASR engine '{engine_id}'") + + +# --- Audio8-ASR-0.1B --------------------------------------------------- + +_model_lock = threading.Lock() +_model = None +_processor = None + + +def _get_model(): + """Lazily loads and caches the Audio8 ASR model/processor as a + process-wide singleton (guarded by a lock so two near-simultaneous + "Auto-Transcribe" clicks - or a batch run alongside one - don't each + start their own multi-GB download/load).""" + global _model, _processor + + with _model_lock: + if _model is not None: + return _model, _processor + + try: + from transformers import AutoModelForCausalLM, AutoProcessor + except ImportError as e: + raise RuntimeError( + "Auto-transcription needs the 'transformers' package " + "(pip install -r requirements.txt)." + ) from e + + try: + processor = AutoProcessor.from_pretrained(AUDIO8_MODEL_ID, trust_remote_code=True) + model = AutoModelForCausalLM.from_pretrained(AUDIO8_MODEL_ID, trust_remote_code=True) + except Exception as e: + raise RuntimeError(f"Failed to load {AUDIO8_MODEL_ID}: {e}") from e + + _model, _processor = model, processor + return _model, _processor + + +def _transcribe_wav_audio8(wav_path: str, max_new_tokens: int = 128) -> str: + """Transcribes `wav_path` (16kHz mono expected; the model resamples if + needed per its model card) to plain text via Audio8-ASR-0.1B's + chat-template audio interface. Blocking/CPU-or-GPU-bound - callers from + the GUI should run this off the main thread (see + `VoiceCloneDock._on_transcribe_clicked`, which schedules it via + `asyncio.to_thread` on the active engine's worker).""" + model, processor = _get_model() + + conversation = [ + { + "role": "user", + "content": [{"type": "audio", "path": wav_path}], + } + ] + + try: + inputs = processor.apply_chat_template( + conversation, + add_generation_prompt=True, + tokenize=True, + return_dict=True, + return_tensors="pt", + ) + output_ids = model.generate(**inputs, max_new_tokens=max_new_tokens) + # Only decode the newly-generated tokens, not the echoed prompt. + prompt_len = inputs["input_ids"].shape[-1] + text = processor.decode(output_ids[0][prompt_len:], skip_special_tokens=True) + return text.strip() + except Exception as e: + raise RuntimeError(f"Transcription failed: {e}") from e + + +# --- Vosk --------------------------------------------------------------- + +_vosk_lock = threading.Lock() +_vosk_models: dict[str, object] = {} # model dir path -> loaded vosk.Model + + +def _get_vosk_model(model_path: str): + """Lazily loads and caches a Vosk model directory as a singleton keyed by + path, so switching between two downloaded models (or repeated + transcriptions against the same one) doesn't reload from disk each time. + Unlike Audio8, there's no single well-known model id - the caller must + point at a model folder they've downloaded and unzipped themselves.""" + with _vosk_lock: + model = _vosk_models.get(model_path) + if model is not None: + return model + + try: + import vosk + except ImportError as e: + raise RuntimeError( + "Vosk transcription needs the 'vosk' package (pip install -r requirements.txt)." + ) from e + + if not model_path: + raise RuntimeError( + "Vosk transcription needs a model folder. Download one from " + "https://alphacephei.com/vosk/models, unzip it, and set " + f"{VOSK_MODEL_PATH_ENV_KEY}= in a .env file at the project " + "root (see .env.example)." + ) + # No existence pre-check: `vosk.Model` fails for a missing folder + # the same way it fails for a folder without model files, and one + # message covers both. + try: + vosk.SetLogLevel(-1) # silence Kaldi's default stderr logging + model = vosk.Model(model_path) + except Exception as e: + raise RuntimeError( + f"Couldn't load a Vosk model from '{model_path}' ({e}). The folder must exist " + "and hold an unzipped model from https://alphacephei.com/vosk/models." + ) from e + + _vosk_models[model_path] = model + return model + + +def _ensure_pcm16_mono(wav_path: str) -> tuple[str, str | None]: + """Returns `(path, temp_path)`: a path to a 16-bit mono PCM WAV holding + `wav_path`'s audio - the exact format `vosk.KaldiRecognizer` requires - + converting into a temp file first if the original isn't already in that + format (stereo, float samples, 8/24/32-bit PCM, etc.). `temp_path` is + that temp file, for the caller to delete when done, and `None` in the + common case (already-correct input), where `path` is `wav_path` itself + unchanged. The caller only ever deletes `temp_path`, so the original + can't be removed by mistake. + + The initial probe goes through the stdlib `wave` module rather than + `soundfile`, since a plain `wave.open` + `getnchannels`/`getsampwidth` + check is cheap and covers the WAV files this format check actually needs + to reject. `wave` can't even open every valid WAV (e.g. 32-bit float + PCM raises `wave.Error: unknown format: 3`), so that failure also routes + into the conversion path below rather than propagating.""" + try: + with wave.open(wav_path, "rb") as wf: + if wf.getnchannels() == 1 and wf.getsampwidth() == 2: + return wav_path, None + except wave.Error: + pass # not something `wave` can parse at all - fall through and convert + + # `soundfile` reads virtually any WAV encoding and, given dtype="int16", + # handles the bit-depth conversion itself (float/8/24/32-bit -> int16). + # `always_2d` keeps mono files at shape (n, 1) so the downmix branch + # below is unconditional regardless of the source channel count. + data, samplerate = sf.read(wav_path, dtype="int16", always_2d=True) + if data.shape[1] > 1: + # Average in a wider dtype first so summing several 16-bit channels + # can't wrap around int16 before the divide brings it back in range. + mono = data.astype(np.int32).mean(axis=1).astype(np.int16) + else: + mono = data[:, 0] + + fd, tmp_path = tempfile.mkstemp(suffix=".wav", prefix="kokoro_vosk_") + os.close(fd) + try: + with wave.open(tmp_path, "wb") as out: + out.setnchannels(1) + out.setsampwidth(2) + out.setframerate(samplerate) + out.writeframes(mono.tobytes()) + except Exception: + os.remove(tmp_path) + raise + return tmp_path, tmp_path + + +def _transcribe_wav_vosk(wav_path: str, model_path: str) -> str: + """Transcribes `wav_path` via Vosk's offline recognizer, first converting + it to the 16-bit mono PCM WAV Vosk requires (see `_ensure_pcm16_mono`) - + unlike Audio8-ASR, Vosk itself doesn't resample or downmix its input.""" + model = _get_vosk_model(model_path) + import vosk + + converted_path, temp_path = _ensure_pcm16_mono(wav_path) + try: + with wave.open(converted_path, "rb") as wf: + recognizer = vosk.KaldiRecognizer(model, wf.getframerate()) + recognizer.SetWords(False) + + pieces = [] + while True: + data = wf.readframes(4000) + if not data: + break + if recognizer.AcceptWaveform(data): + pieces.append(json.loads(recognizer.Result()).get("text", "")) + pieces.append(json.loads(recognizer.FinalResult()).get("text", "")) + except Exception as e: + raise RuntimeError(f"Transcription failed: {e}") from e + finally: + if temp_path is not None: + try: + os.remove(temp_path) + except OSError: + pass + + return " ".join(p for p in pieces if p).strip() + + +# --- dispatch ------------------------------------------------------------- + +def transcribe_wav( + wav_path: str, + engine: str = DEFAULT_ASR_ENGINE, + max_new_tokens: int = 128, + model_path: str | None = None, +) -> str: + """Transcribes `wav_path` via the named engine ("audio8" or "vosk"). + Blocking/CPU-or-GPU-bound - callers from the GUI should run this off the + main thread (see `VoiceCloneDock._on_transcribe_clicked`, which schedules + it via `asyncio.to_thread` on the active engine's worker). + + `model_path` only matters for `engine="vosk"`; leaving it `None` (the + GUI's normal path) falls back to `get_vosk_model_path()` (the + `VOSK_MODEL_PATH` env var) rather than requiring every caller to read + that themselves. Passing a path explicitly - the standalone CLI does - + overrides the environment for that one call.""" + if engine == "audio8": + return _transcribe_wav_audio8(wav_path, max_new_tokens=max_new_tokens) + elif engine == "vosk": + return _transcribe_wav_vosk(wav_path, model_path if model_path is not None else get_vosk_model_path()) + else: + raise ValueError(f"Unknown ASR engine '{engine}'") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print( + "Usage: python -m kokoro_gui.engine.asr " + "[engine=audio8|vosk] [vosk_model_path]\n" + f"(vosk_model_path defaults to the {VOSK_MODEL_PATH_ENV_KEY} env var / .env entry if omitted)" + ) + raise SystemExit(1) + _wav_path = sys.argv[1] + _engine = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_ASR_ENGINE + _model_path = sys.argv[3] if len(sys.argv) > 3 else None + print(transcribe_wav(_wav_path, engine=_engine, model_path=_model_path)) diff --git a/kokoro_gui/engine/audio_fx.py b/kokoro_gui/engine/audio_fx.py new file mode 100644 index 0000000..87d11db --- /dev/null +++ b/kokoro_gui/engine/audio_fx.py @@ -0,0 +1,181 @@ +"""Post-processing audio FX chain (pitch, volume, Pedalboard FX, normalize, trim).""" +import numpy as np +import scipy.signal +from pedalboard import ( + Pedalboard, Reverb, Compressor, HighShelfFilter, LowShelfFilter, + Chorus, Distortion, Phaser, Clipping, Gain, Limiter, + HighpassFilter, LowpassFilter, LadderFilter, Delay, PitchShift, + GSMFullRateCompressor, Bitcrush +) + +# Pitch range the Generation dock's spinbox allows (see pitch_spin.setRange +# in kokoro_gui/qt/docks/generation_dock.py). A preset's `pitch` bypasses +# that spinbox entirely (presets are untrusted JSON - see +# Claude/SECURITY_AUDIT.md), so both places that turn it into a resample +# factor (here and caching.py's ETA speed compensation) clamp to this range +# first: `2 ** (pitch/12.0)` is otherwise unbounded and can OverflowError or +# attempt a multi-GB scipy.signal.resample allocation at extreme values. +PITCH_SEMITONES_MIN = -12.0 +PITCH_SEMITONES_MAX = 12.0 + + +def clamp_pitch_semitones(pitch_semitones): + """Coerces `pitch_semitones` to a float and clamps it to the GUI's + -12..12 range. Falls back to 0.0 (no pitch shift) for a non-numeric + value rather than raising, matching the existing tolerant `config.get` + style used throughout this pipeline.""" + try: + pitch_semitones = float(pitch_semitones) + except (TypeError, ValueError): + return 0.0 + return max(PITCH_SEMITONES_MIN, min(PITCH_SEMITONES_MAX, pitch_semitones)) + + +def process_audio(audio, sr, config): + """The post-processing stage, in the order the numbered comments below + run: trim silence, volume, pitch (resample), the Pedalboard FX chain, + normalize. Reads only `config`, never engine state, so it runs equally + well inside `process_chunk_task` (the whole-document path) and at read + time from `kokoro_gui.audio.post.render` (clip playback/export, where FX + are applied on top of the raw segment file every time the settings + change). Returns the processed mono float array at the same `sr`.""" + # 1. Trim Silence (Simple threshold) + if config.get('trim_silence', False): + threshold = 0.01 + # Find first index > threshold + mask = np.abs(audio) > threshold + if np.any(mask): + start = np.argmax(mask) + end = len(audio) - np.argmax(mask[::-1]) + audio = audio[start:end] + + # 2. Volume / Gain + vol = config.get('volume', 1.0) + if vol != 1.0: + audio = audio * vol + + # 3. Pitch Shift (Resampling) + pitch_semitones = clamp_pitch_semitones(config.get('pitch', 0.0)) + if pitch_semitones != 0.0: + factor = 2 ** (pitch_semitones / 12.0) + new_len = int(len(audio) / factor) + if new_len > 0: + try: + audio = scipy.signal.resample(audio, new_len) + except Exception as e: + print(f"Resample failed: {e}") + + # 4. Pedalboard FX + fx_chain = [] + + if config.get('apply_fx', True): + # --- Guitar / Modulation --- + if config.get('distortion_enabled', False): + drive = config.get('distortion_drive', 25.0) + fx_chain.append(Distortion(drive_db=drive)) + + if config.get('chorus_enabled', False): + fx_chain.append(Chorus( + rate_hz=config.get('chorus_rate', 1.0), + depth=config.get('chorus_depth', 0.25), + mix=config.get('chorus_mix', 0.5) + )) + + if config.get('phaser_enabled', False): + fx_chain.append(Phaser( + rate_hz=config.get('phaser_rate', 1.0), + depth=config.get('phaser_depth', 0.5), + mix=config.get('phaser_mix', 0.5) + )) + + if config.get('clipping_enabled', False): + fx_chain.append(Clipping(threshold_db=config.get('clipping_thresh', -6.0))) + + if config.get('bitcrush_enabled', False): + fx_chain.append(Bitcrush(bit_depth=config.get('bitcrush_depth', 8.0))) + + if config.get('gsm_enabled', False): + fx_chain.append(GSMFullRateCompressor()) + + # --- Filters / EQ --- + # HighPass + if config.get('highpass_enabled', False): + fx_chain.append(HighpassFilter(cutoff_frequency_hz=config.get('highpass_freq', 50.0))) + + # LowPass + if config.get('lowpass_enabled', False): + fx_chain.append(LowpassFilter(cutoff_frequency_hz=config.get('lowpass_freq', 10000.0))) + + # Shelves (Bass/Treble) - Simple EQ + bass_db = config.get('eq_bass', 0.0) + if bass_db != 0.0: + fx_chain.append(LowShelfFilter(cutoff_frequency_hz=250, gain_db=bass_db)) + + treble_db = config.get('eq_treble', 0.0) + if treble_db != 0.0: + fx_chain.append(HighShelfFilter(cutoff_frequency_hz=4000, gain_db=treble_db)) + + # --- Spatial / Time --- + if config.get('pitch_shift_enabled', False): + # High quality pitch shifting without duration change + semitones = config.get('pitch_shift_semitones', 0.0) + if semitones != 0: + fx_chain.append(PitchShift(semitones=semitones)) + + if config.get('delay_enabled', False): + fx_chain.append(Delay( + delay_seconds=config.get('delay_time', 0.5), + feedback=config.get('delay_feedback', 0.0), + mix=config.get('delay_mix', 0.5) + )) + + if config.get('reverb_enabled', False): + fx_chain.append(Reverb( + room_size=config.get('reverb_room_size', 0.5), + damping=config.get('reverb_damping', 0.5), + wet_level=config.get('reverb_wet_level', 0.3), + dry_level=config.get('reverb_dry_level', 1.0), + width=config.get('reverb_width', 1.0) + )) + + # --- Dynamics --- + if config.get('comp_enabled', False): + fx_chain.append(Compressor( + threshold_db=config.get('comp_threshold', -20), + ratio=config.get('comp_ratio', 4), + attack_ms=config.get('comp_attack', 1.0), + release_ms=config.get('comp_release', 100.0) + )) + + if config.get('limiter_enabled', False): + fx_chain.append(Limiter( + threshold_db=config.get('limiter_threshold', -1.0), + release_ms=config.get('limiter_release', 100.0) + )) + + if config.get('gain_enabled', False): + db = config.get('gain_db', 0.0) + if db != 0.0: + fx_chain.append(Gain(gain_db=db)) + + if fx_chain: + try: + board = Pedalboard(fx_chain) + # Pedalboard expects float32 + audio = board(audio, sr) + except Exception as e: + print(f"Pedalboard FX failed: {e}") + + # 5. Normalization + if config.get('normalize', False): + peak = np.max(np.abs(audio)) + if peak > 0: + target_peak = 0.98 + audio = audio / peak * target_peak + + return audio + + +class AudioFXMixin: + def process_audio(self, audio, sr, config): + return process_audio(audio, sr, config) diff --git a/kokoro_gui/engine/caching.py b/kokoro_gui/engine/caching.py new file mode 100644 index 0000000..4a9653e --- /dev/null +++ b/kokoro_gui/engine/caching.py @@ -0,0 +1,456 @@ +"""Per-chunk generation with segment caching, keyed on +schema_version|engine_id|engine_version|text|voice|voice_fingerprint|speed|lang_code +plus a backend's own `extra` inputs (see `compute_cache_key` and `segment_key`). + +`segment_key` is the one hash for a segment: what `Segment.cache_key` +stores, what the dirty check (kokoro_gui/daw/dirty.py) recomputes, and the +stem of the file name in a project dir. Before it existed the dirty check +and `process_chunk_task` computed their keys through two code paths that +disagreed for custom voices (one hashed the name, the other the resolved +absolute path) and for Audio8 (only one folded in the transcript). Every +backend's `process_chunk_task` calls `segment_key(text, config, self)`; the +app calls it with its backend adapter. `backend` is duck-typed: anything +with `resolve_voice_file(name, project_dir)`, `cache_key_extra(config)` and +`engine_version()`, which `CachingMixin` supplies with defaults. + +Two file-naming modes, chosen by `config["segment_naming"]`: + +- unset (legacy, the whole-document and JIT paths): the cache is + `kokoro_engine.CACHE_DIR` and the output file in `out_dir` is named + `__part_.`, post-processed unless + `config["raw_output"]`. +- `"cache_key"` (every clip generation, `.tbaw` plan section 3): `out_dir` + *is* the cache. The file is `_.`, written raw, + once; `CACHE_DIR` is never touched; a present file is never overwritten + (grill TB8). The target is reserved with a `.reserved` marker made + `O_CREAT | O_EXCL`, so two identical clips generating in one batch can't + both write the same file; when the reservation fails, or the caller asks + for `config["regenerate"]`, the clip's take index bumps and the key + changes. Every result dict reports the `take`, `cache_key` and + `engine_version` it landed on, so the caller stamps segments from the + result instead of predicting the key before dispatch. + +Reads `kokoro_engine.CACHE_DIR` qualified, at call time, so tests can keep +monkeypatching it on the `kokoro_engine` module. The synthesis call goes +through `self.get_thread_pipeline(lang_code)`, the one model-specific piece +of this otherwise-generic pipeline, so every backend (kokoro_engine.py, +kokoro_gui/engines/dummy.py, kokoro_gui/engines/audio8_tts.py) reuses this +mixin by supplying its own pipeline and `SAMPLE_RATE`. +""" +import hashlib +import importlib.metadata +import os +import re + +import soundfile as sf +import torch +from pedalboard.io import AudioFile + +import kokoro_engine +from kokoro_gui.engine.audio_fx import clamp_pitch_semitones + +# Bump whenever compute_cache_key's composition or logic changes. Old cache +# entries simply stop matching (new hash algorithm -> new filenames) and +# become dead weight for whatever eventually implements cache eviction +# (ROADMAP) - a bump means the first run after upgrading regenerates the +# whole cache. 3: the voice enters as basename + content fingerprint instead +# of the resolved path, so a key is the same on every machine (the `.tbaw` +# bundle names files by it). `.json` project migration rekeys with +# `schema_version=2` to adopt segments stamped under the old key. +CACHE_SCHEMA_VERSION = 3 + +# Marker suffix for a reserved segment key in "cache_key" naming mode. +RESERVED_SUFFIX = ".reserved" + +# path -> (mtime, fingerprint): avoids re-hashing the same custom-voice file +# on every chunk in a batch run. Mirrors the `self._lexicon_cache` compiled- +# regex cache pattern (the lexicon perf fix) but keyed on filesystem content +# rather than an engine instance, since voice files are process-wide state. +_voice_fingerprint_cache = {} + +AUDIO_FORMATS = ("wav", "flac", "mp3", "ogg") + + +def get_engine_version(engine_id="kokoro"): + """Best-effort version/identity string for `engine_id`, folded into the + cache key so an upgrade that changes model output invalidates stale + entries instead of silently serving old audio under it. The default + `engine_version()` hook on every backend calls this; Audio8 overrides + it with its model id. "kokoro" answers with the installed `kokoro` + package version; any other id falls back to a constant so its entries + are at least self-consistent.""" + if engine_id == "kokoro": + try: + return importlib.metadata.version("kokoro") + except importlib.metadata.PackageNotFoundError: + return "unknown" + return "unknown" + + +def voice_fingerprint(voice_ref): + """Identity string for `voice_ref`: a bare name for a standard voice + (built into the model, never changes), or a content hash for an existing + absolute file path (a custom `.pt` or a reference wav). Remixing and + re-saving a `.pt` under the same name changes what the voice sounds like + without changing its name, and a name-only key can't tell the + difference. The content hash is cached per-file-mtime so a batch run + doesn't re-read the same file for every chunk, and so the dirty check's + per-rehighlight cost is a stat, not a read.""" + if not voice_ref or not (os.path.isabs(voice_ref) and os.path.isfile(voice_ref)): + return voice_ref + + try: + mtime = os.path.getmtime(voice_ref) + except OSError: + return voice_ref + + cached = _voice_fingerprint_cache.get(voice_ref) + if cached is not None and cached[0] == mtime: + return cached[1] + + try: + with open(voice_ref, "rb") as f: + fp = hashlib.sha256(f.read()).hexdigest()[:16] + except OSError: + return voice_ref + + _voice_fingerprint_cache[voice_ref] = (mtime, fp) + return fp + + +def compute_cache_key(text, voice, eff_speed, lang_code, engine_id="kokoro", engine_version=None, extra=None, + schema_version=None, voice_fingerprint_value=None): + """The segment-cache hash: schema_version, engine identity/version, text, + voice name, voice content fingerprint, effective speed, language code, + and an optional `extra` dict of engine-specific inputs that also affect + what gets generated. + + `voice` is the voice's *name* (a built-in id, or a custom file's + basename without extension), never a path: a path would make the key + differ per machine. `voice_fingerprint_value` is the file's content hash + for a custom voice; left `None` it's derived with `voice_fingerprint(voice)`, + which is the bare name for a built-in. `segment_key` below is the + normal way in; this function stays a pure formula so its tests can pin + the format. + + Takes exactly those inputs, not a whole config dict - a config dict + also carries `out_dir`/`filename`/`format`/`normalize`/`trim_silence`/the + FX chain/`num_threads`/etc., none of which affect what gets cached (they + apply after cache read/generation, to the same raw segment - that's the + whole point of caching pre-FX audio). `split_pattern` is excluded for + the same reason: only the text used to generate a segment determines + its content. + + `extra` exists for a backend whose "voice" isn't fully described by a + name + fingerprint - Audio8's zero-shot cloning also takes a reference + *transcript* and sampling knobs. Left `None` it's omitted from the + parts entirely rather than hashed as empty, so a Kokoro key with no + extra is unaffected by the parameter. `schema_version` defaults to + `CACHE_SCHEMA_VERSION`; `.json` project migration passes the old one to + recognise segments stamped before the bump. + """ + if engine_version is None: + engine_version = get_engine_version(engine_id) + if schema_version is None: + schema_version = CACHE_SCHEMA_VERSION + if voice_fingerprint_value is None: + voice_fingerprint_value = voice_fingerprint(voice) + + cache_key_parts = { + "schema_version": schema_version, + "engine_id": engine_id, + "engine_version": engine_version, + "text": text, + "voice": voice, + "voice_fingerprint": voice_fingerprint_value, + "speed": eff_speed, + "lang_code": lang_code, + } + if extra: + for k in sorted(extra): + cache_key_parts[f"extra_{k}"] = extra[k] + to_hash = "|".join(f"{k}={v}" for k, v in cache_key_parts.items()) + return hashlib.sha256(to_hash.encode("utf-8")).hexdigest() + + +def effective_speed(config): + """Speed adjusted for pitch compensation: a pitch shift is done by + resampling, so the model is asked for a correspondingly slower or faster + take. The one place this formula lives; the dirty check imports it.""" + eff_speed = config.get("speed", 1.0) + pitch_semitones = clamp_pitch_semitones(config.get("pitch", 0.0)) + if pitch_semitones != 0.0: + factor = 2 ** (pitch_semitones / 12.0) + eff_speed = eff_speed / factor + return eff_speed + + +def predict_segment_texts(text, split_pattern=r"\n+"): + """The sub-segment texts a pipeline call over `text` is expected to + yield: split on `split_pattern`, stripped, blanks dropped. Mimics + `KPipeline`'s own splitting closely enough that the file count matches; + the cache check and the dirty check both read this one prediction. A + pattern that doesn't compile predicts nothing, which reads as "not + cached".""" + try: + return [t.strip() for t in re.split(split_pattern, text) if t.strip()] + except re.error: + return [] + + +def normalize_voice(voice, backend, project_dir=None): + """`(name, fingerprint)` for `config["voice"]`, which may be a voice + name or a path an earlier step already resolved. A path that exists is + taken as the voice file; a name is looked up through + `backend.resolve_voice_file(name, project_dir)`. The name in the key is + the basename without extension either way, and the fingerprint is the + file's content hash, or the bare name for a built-in voice with no file.""" + if not voice: + return voice, voice + if os.path.isabs(voice) and os.path.isfile(voice): + name = os.path.splitext(os.path.basename(voice))[0] + return name, voice_fingerprint(voice) + name = os.path.basename(voice) + path = backend.resolve_voice_file(name, project_dir) + if path: + return os.path.splitext(os.path.basename(path))[0], voice_fingerprint(path) + return name, name + + +def segment_key(text, config, backend, engine_version=None): + """The one hash for a segment: what `Segment.cache_key` stores and what + the file is named in a project dir. `config["voice"]` may be a name or + an already resolved path; both normalize to (basename without + extension, content fingerprint) through `normalize_voice`. + `backend.cache_key_extra(config)` adds the backend's own generation + inputs (Audio8: transcript + sampling). `config.get("take", 0)` enters + when non-zero, so nothing keyed before takes existed changes. + `engine_version` defaults to `backend.engine_version()`. + `config["lang_code"]` is required, not defaulted: the dirty check and + the engine used to default it differently ("a" vs "English") and one + key can't have two defaults. Pure over its inputs plus the voice file's + content.""" + if "lang_code" not in config: + raise KeyError("segment_key needs config['lang_code']; the config assembler must set it") + name, fingerprint = normalize_voice(config.get("voice"), backend, config.get("project_dir")) + extra = dict(backend.cache_key_extra(config) or {}) + take = int(config.get("take", 0) or 0) + if take: + extra["take"] = take + if engine_version is None: + engine_version = backend.engine_version() + engine_id = config.get("engine_id") or getattr(backend, "id", "kokoro") + return compute_cache_key( + text, name, effective_speed(config), config["lang_code"], engine_id, + engine_version=engine_version, extra=extra or None, voice_fingerprint_value=fingerprint, + ) + + +def _output_format(config): + fmt = str(config.get("format", "wav")).lower() + return fmt if fmt in AUDIO_FORMATS else "wav" + + +def _write_audio(path, audio, sample_rate, label): + try: + with AudioFile(path, "w", samplerate=sample_rate, num_channels=1) as f: + f.write(audio) + except Exception as e: + print(f"{label} write failed: {e}. Fallback to soundfile.") + sf.write(path, audio, sample_rate) + + +def _audio_duration_s(path, sample_rate): + try: + return float(sf.info(path).duration) + except Exception: + try: + data, sr = sf.read(path) + return len(data) / float(sr or sample_rate) + except Exception: + return 0.0 + + +class CachingMixin: + """Generation-with-cache for any backend that supplies + `get_thread_pipeline(lang_code)` (a callable yielding `(graphemes, + phonemes, audio)` triples) and optionally `SAMPLE_RATE` (default 24000) + and `id`. Also the default implementation of the three `segment_key` + hooks; a backend overrides what differs (Audio8: `engine_version` and + `cache_key_extra`).""" + + id = "kokoro" + + # -- segment_key hooks -------------------------------------------------- + + def engine_version(self): + """What goes into the segment key and `manifest.engines[id].version`. + Looked up through this module's `get_engine_version` by name so a + test can monkeypatch it.""" + return get_engine_version(getattr(self, "id", "kokoro")) + + def cache_key_extra(self, config): + """Backend-specific generation inputs folded into `segment_key`. + Nothing for Kokoro: a named or mixed voice, the text and the speed + describe the output.""" + return {} + + def resolve_voice_file(self, name, project_dir=None): + """The file a voice name resolves to (project dir first), or `None` + for a built-in voice. Built on the backend's own `resolve_voice_path`.""" + resolved = self.resolve_voice_path(name, project_dir) + if resolved and os.path.isabs(resolved) and os.path.isfile(resolved): + return resolved + return None + + # -- generation --------------------------------------------------------- + + def process_chunk_task(self, chunk_data, progress_callback): + index, text, config = chunk_data + if self.cancel_event.is_set(): + return [] + + sample_rate = getattr(self, "SAMPLE_RATE", 24000) + lang_code = config.get("lang_code") + eff_speed = effective_speed(config) + key_naming = config.get("segment_naming") == "cache_key" + use_cache = key_naming or bool(config.get("caching", True)) + # config['raw_output'] (set by generate_clip_audio for the clip + # paths) skips process_audio: the segment file is the raw model + # output and kokoro_gui/audio/post.py applies FX/volume/pitch/ + # normalize/trim at read time. Cache-key naming is raw by definition: + # the file it writes is the cache entry. + raw_output = key_naming or bool(config.get("raw_output", False)) + fmt = _output_format(config) + predicted_texts = predict_segment_texts(text, config.get("split_pattern", r"\n+")) + take = int(config.get("take", 0) or 0) + engine_version = self.engine_version() + + cache_hash = None + cache_dir = None + cache_ext = "wav" + cached_segments = [] # [(graphemes, audio), ...] + hit_paths = None + + if key_naming: + cache_dir = config["out_dir"] + cache_ext = fmt + regenerate = bool(config.get("regenerate", False)) + if not predicted_texts: + return [] + while True: + cache_hash = segment_key(text, {**config, "take": take}, self, engine_version) + expected = [os.path.join(cache_dir, f"{cache_hash}_{i}.{cache_ext}") for i in range(len(predicted_texts))] + marker = os.path.join(cache_dir, f"{cache_hash}{RESERVED_SUFFIX}") + if not os.path.exists(marker) and all(os.path.isfile(p) for p in expected): + if not regenerate: + hit_paths = expected + break + # A present set is never written over (another clip may + # play it): a regenerate lands on the next take. + take += 1 + continue + try: + fd = os.open(marker, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.close(fd) + except FileExistsError: + take += 1 + continue + # Reserved. A partial earlier attempt (some files present, the + # marker gone) also lands here through the `all(...)` check + # failing: those files get overwritten under this same key, + # which is what a resume of the same inputs should do. + break + elif use_cache: + cache_dir = kokoro_engine.CACHE_DIR + cache_hash = segment_key(text, config, self, engine_version) + try: + if predicted_texts: + loaded = [] + all_exist = True + for i, seg_text in enumerate(predicted_texts): + f_path = os.path.join(cache_dir, f"{cache_hash}_{i}.wav") + if not os.path.exists(f_path): + all_exist = False + break + audio_data, _ = sf.read(f_path) + loaded.append((seg_text, audio_data)) + if all_exist and loaded: + cached_segments = loaded + except Exception as e: + print(f"Cache check error: {e}") + cached_segments = [] + + def result(path, graphemes, duration): + return { + "path": path, "text": graphemes, "duration": duration, "seg_idx": index, + "raw": raw_output, "take": take, "cache_key": cache_hash, "engine_version": engine_version, + } + + if hit_paths is not None: + # Cache-key naming hit: the files are the segments. No write. + chunk_files = [] + for path, graphemes in zip(hit_paths, predicted_texts): + if self.cancel_event.is_set(): + break + if progress_callback: + progress_callback(len(graphemes), graphemes) + chunk_files.append(result(path, graphemes, _audio_duration_s(path, sample_rate))) + return chunk_files + + chunk_files = [] + sub_idx = 0 + base_name = f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_part{index}" + + def process_and_save(graphemes, raw_audio): + nonlocal sub_idx + if key_naming: + path = os.path.join(cache_dir, f"{cache_hash}_{sub_idx}.{cache_ext}") + processed_audio = raw_audio + else: + path = os.path.join(config["out_dir"], f"{base_name}_{sub_idx}.{fmt}") + processed_audio = raw_audio if raw_output else self.process_audio(raw_audio, sample_rate, config) + _write_audio(path, processed_audio, sample_rate, type(self).__name__) + return result(path, graphemes, len(processed_audio) / float(sample_rate)) + + try: + if cached_segments: + for graphemes, audio in cached_segments: + if self.cancel_event.is_set(): + break + if progress_callback: + progress_callback(len(graphemes), graphemes) + chunk_files.append(process_and_save(graphemes, audio)) + sub_idx += 1 + else: + pipeline = self.get_thread_pipeline(lang_code) if lang_code else self.get_thread_pipeline() + if not pipeline: + raise RuntimeError(f"Failed to initialize pipeline ({lang_code}) in thread.") + + generator = pipeline(text, voice=config["voice"], speed=eff_speed, + split_pattern=config.get("split_pattern", r"\n+")) + + for graphemes, phonemes, audio in generator: + if self.cancel_event.is_set(): + break + if progress_callback: + progress_callback(len(graphemes), graphemes) + if isinstance(audio, torch.Tensor): + audio = audio.cpu().numpy() + + if use_cache and cache_hash and not key_naming: + try: + sf.write(os.path.join(cache_dir, f"{cache_hash}_{sub_idx}.wav"), audio, sample_rate) + except Exception as e: + print(f"Cache write error: {e}") + + chunk_files.append(process_and_save(graphemes, audio)) + sub_idx += 1 + finally: + if key_naming and cache_hash: + try: + os.remove(os.path.join(cache_dir, f"{cache_hash}{RESERVED_SUFFIX}")) + except OSError: + pass + + return chunk_files diff --git a/kokoro_gui/engine/conversion.py b/kokoro_gui/engine/conversion.py new file mode 100644 index 0000000..7d81283 --- /dev/null +++ b/kokoro_gui/engine/conversion.py @@ -0,0 +1,499 @@ +"""Batch conversion lifecycle: single-clip preview generation, the parallel +chunked "Standard" batch pipeline (`start_conversion` -> `_process_text_async`), +and the WAV-segment combiner shared with JIT mode. + +`generate_preview` calls `self.get_thread_pipeline(lang_code)` rather than +`kokoro_engine.get_thread_pipeline` directly - that's the one genuinely +model-specific piece of this otherwise-generic mixin, and going through +`self` lets a non-Kokoro backend (kokoro_gui/engines/dummy.py) reuse this +whole mixin by supplying its own `get_thread_pipeline`. `KokoroEngine.get_thread_pipeline` +(kokoro_engine.py) itself still calls the module-level thread-local getter by +name, so `monkeypatch.setattr(kokoro_engine, "get_thread_pipeline", ...)` in +tests still takes effect. +""" +import asyncio +import concurrent.futures +import os +import threading +import time + +import numpy as np +import soundfile as sf +import torch +from pedalboard.io import AudioFile + +from kokoro_gui.engine import stats as generation_stats +from kokoro_gui.engine.presets import ALLOWED_FX_PRESET_KEYS, ALLOWED_PRESET_KEYS, filter_allowed_keys +from kokoro_gui.engine.time_utils import format_duration + +# Below this fraction of the *current* run's own chars processed, the +# observed-this-run rate is too noisy (one slow/fast chunk dominates it) to +# trust on its own - blend it with the historical per-engine rate instead of +# switching to it outright. See `on_chunk_progress` below. +_OBSERVED_RATE_TRUST_FRACTION = 0.15 + + +class ConversionMixin: + async def generate_preview(self, text, voice, speed, output_path, extra_config=None, voice_tensor=None, lang_code='a'): + def _gen(): + # Use specific lang code for preview + p = self.get_thread_pipeline(lang_code) + if not p: return False + + # Kokoro/Dummy both output 24000Hz; a backend whose model outputs a + # different rate (e.g. Audio8Engine's 44100Hz) sets an instance + # `SAMPLE_RATE` attribute to override this default. + sr = getattr(self, "SAMPLE_RATE", 24000) + + try: + ms_segments = self.parse_multispeaker_text(text) + # Truncate to first 2 segments for preview if many + if len(ms_segments) > 2: + ms_segments = ms_segments[:2] + + all_pieces = [] + + for speaker_name, fx_name, segment_text in ms_segments: + # Apply Lexicon if provided in extra_config + if extra_config and 'lexicon' in extra_config: + segment_text = self.apply_lexicon(segment_text, extra_config['lexicon']) + + # Truncate segment text if too long for preview + if len(segment_text) > 500: + segment_text = segment_text[:500] + + target_voice = voice + target_speed = speed + target_extra = extra_config.copy() if extra_config else {} + + if speaker_name: + preset = self.load_preset(speaker_name) + if preset: + target_voice = preset.get('voice', target_voice) + target_speed = preset.get('speed', target_speed) + if 'volume' in preset: target_extra['volume'] = preset['volume'] + if 'pitch' in preset: target_extra['pitch'] = preset['pitch'] + if 'normalize' in preset: target_extra['normalize'] = preset['normalize'] + if 'trim' in preset: target_extra['trim_silence'] = preset['trim'] + # If speaker preset has an FX preset, it can be overridden by the colon syntax + if 'fx_preset' in preset: + target_extra['fx_preset'] = preset['fx_preset'] + if 'apply_fx' in preset: + target_extra['apply_fx'] = preset['apply_fx'] + + if fx_name: + fx_preset = self.load_fx_preset(fx_name, (extra_config or {}).get("project_dir")) + if fx_preset: + target_extra.update(filter_allowed_keys(fx_preset, ALLOWED_FX_PRESET_KEYS)) + target_extra['apply_fx'] = True + target_extra['fx_preset'] = fx_name + + # Resolve voice + if voice_tensor is not None and not speaker_name: + # Only use voice_tensor if no speaker name (direct preview of mix) + actual_voice = "_preview_temp" + p.voices[actual_voice] = voice_tensor + else: + actual_voice = self.resolve_voice_path(target_voice) + + # Pitch Compensation + eff_speed = target_speed + pitch_st = target_extra.get('pitch', 0.0) + if pitch_st != 0.0: + factor = 2 ** (pitch_st / 12.0) + eff_speed = target_speed / factor + + # Generate + generator = p(segment_text, voice=actual_voice, speed=eff_speed, split_pattern=r"\n+") + for _, _, audio in generator: + if isinstance(audio, torch.Tensor): + audio = audio.cpu().numpy() + # Post Process + audio = self.process_audio(audio, sr, target_extra) + all_pieces.append(audio) + + if not all_pieces: + return False + + full_audio = np.concatenate(all_pieces) + + try: + with AudioFile(output_path, 'w', samplerate=sr, num_channels=1) as f: + f.write(full_audio) + return True + except Exception as e: + print(f"Preview write error: {e}") + # Fallback + sf.write(output_path, full_audio, sr) + return True + except Exception as e: + print(f"Preview error: {e}") + return False + + return await asyncio.to_thread(_gen) + + async def generate_clip_audio(self, chunk_data, progress_callback=None): + """Generates (or cache-hits) audio for a single already-resolved + `(index, text, config)` chunk via the existing, unmodified + `process_chunk_task` (kokoro_gui/engine/caching.py) - the per-clip + Generate entry point for the DAW redesign's timeline dock + (Claude/PLAN_daw_ui_ux_redesign.md). A thin wrapper, not a + reimplementation: `process_chunk_task`'s signature and `chunk_data` + shape are untouched. + + Replicates three things every other caller of `process_chunk_task` + (namely `start_conversion`/`_process_text_async`) already does + before dispatch, which a lone per-clip call has no one else to do + for it: + - Resolves `config['voice']` - `process_chunk_task` uses it verbatim + in both the cache key and the pipeline call, so skipping this + would silently break custom voices. + - Creates `config['out_dir']` if it doesn't exist yet - a document + whose output folder was never created by a prior whole-document + run would otherwise crash on write. + - Clears `self.cancel_event` - left set by an earlier cancelled run, + `process_chunk_task`'s first line would otherwise silently return + `[]` for what looks like a fresh request. + + Also sets `config["raw_output"]`: clip segments are stored as raw + model output and post-processed on read (kokoro_gui/audio/post.py), + so an FX change is audible without regenerating. Only the no-clips + whole-document path still bakes FX into its files. + """ + index, text, config = chunk_data + config = dict(config) + config["voice"] = self.resolve_voice_path(config["voice"]) + config["raw_output"] = True + os.makedirs(config["out_dir"], exist_ok=True) + self.cancel_event.clear() + return await asyncio.to_thread(self.process_chunk_task, (index, text, config), progress_callback) + + async def generate_dirty_clips(self, clips_with_configs, progress_callback=None): + """Batch dirty-scoped generation for the DAW timeline's consolidated + "Generate" action (item 3 of the DAW-for-text remaining-work + roadmap). `clips_with_configs` is `list[(clip_id, text, config)]`, + one entry per `Document.dirty_clips()` clip already resolved into an + engine config by the caller (`kokoro_gui/qt/app.py`'s + `_assemble_clip_config`) - kept Document-agnostic on this side. + + Assigns each entry a batch-local unique `index` via `enumerate()` - + required, not cosmetic: every clip's config in one batch shares the + same `filename`/`time_id`, and `process_chunk_task`'s output + filenames are `{filename}_{time_id}_part{index}_{sub_idx}.{fmt}`, so + reusing one `index` (e.g. always 0) across clips would collide on + disk. + + Bounds concurrency with `asyncio.Semaphore(num_threads)` (read from + the first entry's config, defaulting to 1 if absent) wrapping + per-clip calls to the existing `generate_clip_audio` - not + reimplementing its voice-resolution/out_dir-creation/ + cancel_event-clearing. + + Gathers with `return_exceptions=True`: one clip's exception never + aborts the batch, mirroring `_process_text_async`'s existing policy + for chunk failures within a single run. + + Cancel-mid-batch race (specific to batching, and the reason this + isn't just "call generate_clip_audio in a loop"): + `generate_clip_audio` unconditionally clears `self.cancel_event` as + its first action - correct for a lone call, but in a batch a clip + still queued behind a full semaphore when the user cancels + (`cancel_event.set()`) would otherwise reach its turn, clear the + shared event, and run to completion (and let everything queued + behind it run too) as if nothing had been cancelled. Each per-clip + wrapper checks `cancel_event.is_set()` immediately before calling + `generate_clip_audio` and skips the call entirely when set, + recording a distinct "cancelled" outcome instead of a generic + failure. + + `progress_callback`, if given, is called once per completed clip as + `progress_callback(clip_id, success)` - deliberately NOT forwarded + into `generate_clip_audio`'s own char-level progress_callback (whose + `(char_count, snippet)` signature means something different); a + caller wanting per-clip batch progress (e.g. `TimelineDock`'s + `batchGenerationProgress` signal) gets one call per clip rather than + a burst of sub-segment character counts. + + Returns one outcome dict per clip, in the same order as + `clips_with_configs`: + `{"clip_id", "success", "results", "error", "cancelled"}`. + """ + if not clips_with_configs: + return [] + + num_threads = clips_with_configs[0][2].get("num_threads", 1) or 1 + semaphore = asyncio.Semaphore(max(1, num_threads)) + + async def _run_one(index, clip_id, text, config): + async with semaphore: + if self.cancel_event.is_set(): + if progress_callback: + progress_callback(clip_id, False) + return { + "clip_id": clip_id, "success": False, "results": [], + "error": "Cancelled.", "cancelled": True, + } + try: + results = await self.generate_clip_audio((index, text, config)) + success = bool(results) + error = "" if success else "Generation produced no audio (cancelled or empty text)." + if progress_callback: + progress_callback(clip_id, success) + return { + "clip_id": clip_id, "success": success, "results": results, + "error": error, "cancelled": False, + } + except Exception as e: + if progress_callback: + progress_callback(clip_id, False) + return { + "clip_id": clip_id, "success": False, "results": [], + "error": str(e), "cancelled": False, + } + + tasks = [ + _run_one(index, clip_id, text, config) + for index, (clip_id, text, config) in enumerate(clips_with_configs) + ] + raw_results = await asyncio.gather(*tasks, return_exceptions=True) + + outcomes = [] + for i, result in enumerate(raw_results): + if isinstance(result, Exception): + clip_id = clips_with_configs[i][0] + outcomes.append({ + "clip_id": clip_id, "success": False, "results": [], + "error": str(result), "cancelled": False, + }) + else: + outcomes.append(result) + return outcomes + + async def smart_combine(self, file_paths, output_path, update_callback): + def combine_worker(): + total_files = len(file_paths) + sr = getattr(self, "SAMPLE_RATE", 24000) + try: + # Use Pedalboard AudioFile + with AudioFile(output_path, 'w', samplerate=sr, num_channels=1) as out_f: + for i, fp in enumerate(file_paths): + if self.cancel_event.is_set(): break + try: + # Read with SoundFile (reliable for reading various formats) + data, _ = sf.read(fp) + out_f.write(data) + if update_callback: update_callback((i + 1) / total_files) + except Exception as e: + print(f"Failed to read segment {fp}: {e}") + except Exception as e: + print(f"Combine failed: {e}") + await asyncio.to_thread(combine_worker) + + def start_conversion(self, text, config): + # Resolve voice path once before distribution + config['voice'] = self.resolve_voice_path(config['voice']) + + self.cancel_event.clear() + self.worker.run_coro(self._process_text_async(text, config)) + + async def _process_text_async(self, text, config): + # Bound outside the try so the `finally` below can always report a + # completed (or partially-completed, e.g. cancelled) generation to + # generation_stats - even a run that dies before `start_time` is set + # leaves these at their no-op defaults (record_generation skips + # zero/negative input). + engine_id = config.get('engine_id', 'unknown') + start_time = None + total_chars = 0 + total_words = 0 + processed_chars = 0 + try: + if self.on_status: self.on_status("Preparing text...", False) + os.makedirs(config['out_dir'], exist_ok=True) + + num_workers = config.get('num_threads', 1) + + # Multispeaker Support + ms_segments = self.parse_multispeaker_text(text) + tasks_data = [] + + lexicon = config.get('lexicon', {}) + + for speaker_name, fx_name, segment_text in ms_segments: + # Apply Lexicon + segment_text = self.apply_lexicon(segment_text, lexicon) + + seg_config = config.copy() + if speaker_name: + preset = self.load_preset(speaker_name) + if preset: + seg_config.update(filter_allowed_keys(preset, ALLOWED_PRESET_KEYS)) + if 'trim' in preset: + seg_config['trim_silence'] = preset['trim'] + # Resolve voice path for the new voice + seg_config['voice'] = self.resolve_voice_path(seg_config['voice']) + else: + if self.on_status: self.on_status(f"Warning: Preset '{speaker_name}' not found.", False) + + if fx_name: + fx_preset = self.load_fx_preset(fx_name, config.get("project_dir")) + if fx_preset: + seg_config.update(filter_allowed_keys(fx_preset, ALLOWED_FX_PRESET_KEYS)) + seg_config['apply_fx'] = True + seg_config['fx_preset'] = fx_name + else: + if self.on_status: self.on_status(f"Warning: FX Preset '{fx_name}' not found.", False) + + # Split this segment into sub-chunks for parallel processing + # Use same character limit as original + seg_chunks = self.smart_split(segment_text, chunk_size=5000 if num_workers > 1 else 1000000) + for chunk in seg_chunks: + # (index, text, config) + tasks_data.append((len(tasks_data), chunk, seg_config)) + + total_chunks = len(tasks_data) + if total_chunks == 0: + if self.on_status: self.on_status("No text to process.", False) + if self.on_finish: self.on_finish() + return + + total_chars = sum(len(d[1]) for d in tasks_data) + total_words = sum(len(d[1].split()) for d in tasks_data) + start_time = time.time() + phase_weight = 0.9 if config.get('combine', True) else 1.0 + + # Seed the ETA from this engine's own generation history (see + # kokoro_gui/engine/stats.py) so there's a real estimate from the + # very first progress tick instead of "--:--" until enough of + # *this* run has completed to extrapolate from. Kept separate per + # engine_id since e.g. Audio8's single-lock throughput is nowhere + # near Kokoro's per-thread pipelines. + historical_rate = generation_stats.estimate_chars_per_sec(engine_id) + + if self.on_status: self.on_status(f"Queued {total_chunks} blocks. Starting {num_workers} workers...", False) + + if historical_rate and total_chars > 0 and self.on_progress: + initial_eta = format_duration(total_chars / (historical_rate * phase_weight)) + self.on_progress(0, 0.0, initial_eta, "Estimating from past runs...") + + # Progress tracker + progress_lock = threading.Lock() + + def on_chunk_progress(char_count, snippet): + nonlocal processed_chars + with progress_lock: + processed_chars += char_count + + # Calculate progress and call main callback + elapsed = time.time() - start_time + gen_fraction = min(processed_chars / total_chars, 1.0) + total_fraction = gen_fraction * phase_weight + + # Estimate ETA. Blend this run's own observed rate with the + # historical per-engine rate, trusting the observed rate more + # as more of *this* run's chars have actually gone through - + # early on, one slow or fast chunk would otherwise swing a + # purely-observed estimate wildly. + eta_str = "--:--" + observed_rate = (processed_chars / elapsed) if elapsed > 0 else 0.0 + if historical_rate: + confidence = min(gen_fraction / _OBSERVED_RATE_TRUST_FRACTION, 1.0) + rate = confidence * observed_rate + (1 - confidence) * historical_rate + elif gen_fraction > 0.01: + # No history for this engine yet - fall back to the + # original behavior of extrapolating from this run alone, + # gated to a sliver of progress so one noisy first chunk + # can't produce a wild estimate. + rate = observed_rate + else: + rate = 0.0 + + if rate > 0: + total_est = total_chars / (rate * phase_weight) + rem = max(0.0, total_est - elapsed) + eta_str = format_duration(rem) + + clean_snip = snippet.replace("\n", " ").strip() + if len(clean_snip) > 40: clean_snip = clean_snip[:37] + "..." + + if self.on_progress: + self.on_progress(total_fraction * 100, elapsed, eta_str, f"Processing: {clean_snip}") + + # All generated files list + all_generated_files = [None] * total_chunks + + loop = asyncio.get_running_loop() + + with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: + futures = [] + for i, data in enumerate(tasks_data): + fut = loop.run_in_executor(executor, self.process_chunk_task, data, on_chunk_progress) + futures.append(fut) + + results = await asyncio.gather(*futures, return_exceptions=True) + + for i, result in enumerate(results): + if isinstance(result, Exception): + print(f"Chunk {i} failed: {result}") + if self.on_status: self.on_status(f"Error in chunk {i}", True) + else: + all_generated_files[i] = result + + if self.cancel_event.is_set(): + if self.on_status: self.on_status("Conversion Cancelled.", False) + if self.on_finish: self.on_finish() + return + + final_segment_list = [] + for sublist in all_generated_files: + if sublist: final_segment_list.extend(sublist) + + final_file_paths = [seg['path'] for seg in final_segment_list] + + if self.on_status: self.on_status(f"Generated {len(final_segment_list)} segments. Processing outputs...", False) + + if config.get('export_subtitles', False) and final_segment_list: + srt_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_combined.srt") + self.generate_srt(final_segment_list, srt_path) + + if config.get('combine', True) and final_file_paths: + if self.on_status: self.on_status("Merging audio files...", False) + + fmt = config.get('format', 'wav').lower() + combine_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_combined.{fmt}") + + def on_merge_progress(frac): + total_fraction = (1.0 * phase_weight) + (frac * (1.0 - phase_weight)) + elapsed = time.time() - start_time + if self.on_progress: + self.on_progress(total_fraction * 100, elapsed, "00:00", f"Merging... {int(frac*100)}%") + + await self.smart_combine(final_file_paths, combine_path, on_merge_progress) + + if not config.get('separate', True): + for p in final_file_paths: + try: os.remove(p) + except Exception: pass + + if self.on_status: self.on_status(f"Done! Saved: {combine_path}", False) + else: + if self.on_status: self.on_status("Conversion Complete!", False) + + if self.on_progress: + self.on_progress(100, time.time() - start_time, "00:00", "Completed") + + except Exception as e: + print(e) + if self.on_status: self.on_status(f"Critical Error: {e}", True) + finally: + # Feed this run's actual throughput back into the per-engine + # history, whether it finished, errored, or was cancelled + # partway - `processed_chars`/`start_time` reflect however much + # actually got generated, and record_generation() itself skips + # storing anything if that turned out to be zero/nothing timed. + if start_time is not None: + elapsed_total = time.time() - start_time + chars_for_stats = min(processed_chars, total_chars) if total_chars else processed_chars + words_for_stats = int(total_words * (chars_for_stats / total_chars)) if total_chars else 0 + generation_stats.record_generation(engine_id, chars_for_stats, words_for_stats, elapsed_total) + if self.on_finish: self.on_finish() diff --git a/kokoro_gui/engine/jit.py b/kokoro_gui/engine/jit.py new file mode 100644 index 0000000..9972a29 --- /dev/null +++ b/kokoro_gui/engine/jit.py @@ -0,0 +1,184 @@ +"""Real-time ("JIT") generation and playback: a generation thread fills a queue +while a playback thread drains it, with buffer management for immediate streaming. + +Calls `kokoro_engine.playback.play` qualified, at call time, so tests can keep +monkeypatching `kokoro_engine.playback` to a `MagicMock()` (see the `engine` +fixture in `tests/conftest.py`) without a real audio device ever being touched. +""" +import asyncio +import os +import time + +import kokoro_engine +from kokoro_gui.engine.presets import ALLOWED_FX_PRESET_KEYS, ALLOWED_PRESET_KEYS, filter_allowed_keys + + +class JITMixin: + def start_jit_conversion(self, text, config): + """Starts real-time generation and playback.""" + config['voice'] = self.resolve_voice_path(config['voice']) + self.cancel_event.clear() + self.worker.run_coro(self._process_jit_async(text, config)) + + async def _process_jit_async(self, text, config): + """ + JIT Logic: + 1. Parse text into segments. + 2. Generation thread fills a queue. + 3. Playback thread consumes the queue. + 4. Buffer management (2 mins ahead). + """ + try: + if self.on_status: self.on_status("JIT: Preparing...", False) + os.makedirs(config['out_dir'], exist_ok=True) + + # 1. Parse segments + ms_segments = self.parse_multispeaker_text(text) + all_text_segments = [] + lexicon = config.get('lexicon', {}) + + for speaker_name, fx_name, segment_text in ms_segments: + segment_text = self.apply_lexicon(segment_text, lexicon) + seg_config = config.copy() + seg_config['format'] = 'wav' # Force wav for JIT playback compatibility + if speaker_name: + preset = self.load_preset(speaker_name) + if preset: + seg_config.update(filter_allowed_keys(preset, ALLOWED_PRESET_KEYS)) + if 'trim' in preset: + seg_config['trim_silence'] = preset['trim'] + seg_config['format'] = 'wav' # Ensure preset doesn't override format to non-wav + seg_config['voice'] = self.resolve_voice_path(seg_config['voice']) + + if fx_name: + fx_preset = self.load_fx_preset(fx_name, config.get("project_dir")) + if fx_preset: + seg_config.update(filter_allowed_keys(fx_preset, ALLOWED_FX_PRESET_KEYS)) + seg_config['apply_fx'] = True + seg_config['fx_preset'] = fx_name + + # Split into smaller chunks for JIT (sentences/short paragraphs) + chunks = self.smart_split(segment_text, chunk_size=500) # Small chunks for fast start + for c in chunks: + all_text_segments.append((c, seg_config)) + + if not all_text_segments: + if self.on_status: self.on_status("No text for JIT.", False) + if self.on_finish: self.on_finish() + return + + # Queues and State + audio_queue = asyncio.Queue() + played_segments = [] + generated_but_unplayed = [] + total_segments = len(all_text_segments) + + playback_finished_event = asyncio.Event() + + # --- Generation Loop --- + async def generation_loop(): + nonlocal total_segments + try: + for i, (seg_text, seg_config) in enumerate(all_text_segments): + if self.cancel_event.is_set(): break + + while audio_queue.qsize() > 10 and not self.cancel_event.is_set(): + await asyncio.sleep(0.5) + + if self.cancel_event.is_set(): break + + if self.on_status: + self.on_status(f"JIT: Generating chunk {i+1}/{total_segments}...", False) + + chunk_files = await asyncio.to_thread(self.process_chunk_task, (i, seg_text, seg_config), None) + + for cf in chunk_files: + await audio_queue.put(cf) + generated_but_unplayed.append(cf) + except Exception as e: + print(f"JIT Gen Error: {e}") + finally: + # Always signal end + await audio_queue.put(None) + + # --- Playback Loop --- + async def playback_loop(): + nonlocal played_segments + start_time = time.time() + try: + idx = 0 + while not self.cancel_event.is_set(): + # Use wait_for to allow checking cancel_event periodically + try: + item = await asyncio.wait_for(audio_queue.get(), timeout=1.0) + except asyncio.TimeoutError: + continue + + if item is None: break # End of stream + + idx += 1 + if self.on_status: + self.on_status(f"JIT: Playing chunk {idx}...", False) + + clean_snip = item['text'].replace("\n", " ").strip() + if len(clean_snip) > 40: clean_snip = clean_snip[:37] + "..." + + elapsed = time.time() - start_time + if self.on_progress: + percent = (idx / total_segments) * 100 + self.on_progress(percent, elapsed, "--:--", f"Playing: {clean_snip}") + + # Play audio (Synchronously in thread) + await asyncio.to_thread(kokoro_engine.playback.play, item['path'], True) + + played_segments.append(item) + if item in generated_but_unplayed: + generated_but_unplayed.remove(item) + + except Exception as e: + print(f"JIT Playback Error: {e}") + finally: + playback_finished_event.set() + + # Start loops + gen_task = asyncio.create_task(generation_loop()) + play_task = asyncio.create_task(playback_loop()) + + await playback_finished_event.wait() + + # --- Cleanup and Save State --- + if self.cancel_event.is_set(): + if self.on_status: self.on_status("JIT Stopped. Saving state...", False) + else: + if self.on_status: self.on_status("JIT Finished.", False) + + # Combine what was played/generated so far + all_work_so_far = played_segments + generated_but_unplayed + if all_work_so_far: + combined_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_jit_output.wav") + await self.smart_combine([s['path'] for s in all_work_so_far], combined_path, None) + if self.on_status: self.on_status(f"JIT Output saved: {combined_path}", False) + + # Save remaining text + if generated_but_unplayed: + first_remaining_idx = generated_but_unplayed[0]['seg_idx'] + elif played_segments: + first_remaining_idx = played_segments[-1]['seg_idx'] + 1 + else: + first_remaining_idx = 0 + + remaining_text = "" + for i in range(first_remaining_idx, total_segments): + remaining_text += all_text_segments[i][0] + "\n\n" + + if remaining_text: + rem_path = os.path.join(config['out_dir'], f"{config.get('filename', 'output')}_{config.get('time_id', '0')}_remaining.txt") + with open(rem_path, "w", encoding="utf-8") as f: + f.write(remaining_text) + if self.on_status: self.on_status(f"Remaining text saved: {rem_path}", False) + + except Exception as e: + print(f"JIT Critical Error: {e}") + if self.on_status: self.on_status(f"JIT Error: {e}", True) + finally: + if self.on_finish: self.on_finish() diff --git a/kokoro_gui/engine/lexicon.py b/kokoro_gui/engine/lexicon.py new file mode 100644 index 0000000..71e1ce6 --- /dev/null +++ b/kokoro_gui/engine/lexicon.py @@ -0,0 +1,27 @@ +"""Lexicon (find/replace) substitution, applied to text before synthesis.""" +import re + + +class LexiconMixin: + def apply_lexicon(self, text, lexicon): + """ + Applies a dictionary of replacements to the text. + Case-insensitive finding, preserves case of replacement. + """ + if not lexicon: + return text + + for src, dest in lexicon.items(): + if not src: continue + try: + # Use cached pattern if available to avoid repeated recompilation overhead + if src not in self._lexicon_cache: + # Escape the search term to treat it as literal text + self._lexicon_cache[src] = re.compile(re.escape(src), re.IGNORECASE) + + pattern = self._lexicon_cache[src] + text = pattern.sub(dest, text) + except Exception as e: + print(f"Lexicon error for '{src}': {e}") + + return text diff --git a/kokoro_gui/engine/presets.py b/kokoro_gui/engine/presets.py new file mode 100644 index 0000000..36e04a9 --- /dev/null +++ b/kokoro_gui/engine/presets.py @@ -0,0 +1,82 @@ +"""Loading speaker presets (`presets/*.json`) and FX presets (`presets/fx/*.json`) +used by multi-speaker script parsing. Directory names are fixed constants, not +monkeypatched by any test, so no `import kokoro_engine` qualification is needed here. +""" +import json +import os + +# Keys a *speaker* preset (presets/*.json) is allowed to merge into a +# trusted per-segment config. Mirrors exactly what the Generation dock's +# `_save_preset_dialog` writes (kokoro_gui/qt/docks/generation_dock.py) - +# notably never out_dir/filename/time_id, which a preset file must not be +# able to steer (presets are shareable JSON with no import/export vetting - +# see Claude/SECURITY_AUDIT.md). +ALLOWED_PRESET_KEYS = frozenset({ + "voice", "speed", "volume", "pitch", "split_pattern", "normalize", + "trim", "format", "apply_fx", "fx_preset", +}) + +# Keys an *FX* preset (presets/fx/*.json) is allowed to merge. Mirrors +# kokoro_gui/qt/spec.py's FX_PRESET_KEYS (duplicated here rather than +# imported, since kokoro_gui/engine is meant to stay independent of the Qt +# frontend - see CLAUDE.md). +ALLOWED_FX_PRESET_KEYS = frozenset({ + "reverb_enabled", "reverb_room_size", "reverb_wet_level", "reverb_damping", + "reverb_dry_level", "reverb_width", + "eq_bass", "eq_treble", + "comp_enabled", "comp_threshold", "comp_ratio", "comp_attack", "comp_release", + "distortion_enabled", "distortion_drive", + "chorus_enabled", "chorus_rate", "chorus_depth", "chorus_mix", + "phaser_enabled", "phaser_rate", "phaser_depth", "phaser_mix", + "clipping_enabled", "clipping_thresh", + "bitcrush_enabled", "bitcrush_depth", + "gsm_enabled", + "highpass_enabled", "highpass_freq", + "lowpass_enabled", "lowpass_freq", + "delay_enabled", "delay_time", "delay_feedback", "delay_mix", + "pitch_shift_enabled", "pitch_shift_semitones", + "limiter_enabled", "limiter_threshold", "limiter_release", + "gain_enabled", "gain_db", +}) + + +def filter_allowed_keys(preset_dict, allowed_keys): + """Returns a copy of `preset_dict` containing only keys in + `allowed_keys` - used to whitelist which fields a loaded preset JSON is + allowed to merge into a trusted config dict, since preset files are + untrusted, shareable input (see Claude/SECURITY_AUDIT.md).""" + return {k: v for k, v in preset_dict.items() if k in allowed_keys} + + +class PresetsMixin: + def load_preset(self, name): + """Loads a preset from the presets directory.""" + # Sanitize name to prevent path traversal + safe_name = os.path.basename(name) + preset_path = os.path.join("presets", f"{safe_name}.json") + if os.path.exists(preset_path): + try: + with open(preset_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f"Error loading preset {name}: {e}") + return None + + def load_fx_preset(self, name, project_dir=None): + """Loads an FX preset: the open project's `fx/.json` first + (a `.tbaw` bundles the presets it names), then presets/fx.""" + # Sanitize name to prevent path traversal + safe_name = os.path.basename(name) + candidates = [] + if project_dir: + candidates.append(os.path.join(project_dir, "fx", f"{safe_name}.json")) + candidates.append(os.path.join("presets", "fx", f"{safe_name}.json")) + for fx_path in candidates: + if os.path.exists(fx_path): + try: + with open(fx_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f"Error loading FX preset {name}: {e}") + return None + return None diff --git a/kokoro_gui/engine/srt.py b/kokoro_gui/engine/srt.py new file mode 100644 index 0000000..34b9617 --- /dev/null +++ b/kokoro_gui/engine/srt.py @@ -0,0 +1,26 @@ +"""SRT subtitle file generation from a list of generated segments.""" + + +class SrtMixin: + def generate_srt(self, segments, output_path): + def format_time(seconds): + millis = int((seconds - int(seconds)) * 1000) + seconds = int(seconds) + minutes, seconds = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + return f"{hours:02}:{minutes:02}:{seconds:02},{millis:03}" + + try: + with open(output_path, "w", encoding="utf-8") as f: + current_time = 0.0 + for i, seg in enumerate(segments): + start = current_time + end = current_time + seg['duration'] + f.write(f"{i+1}\n") + f.write(f"{format_time(start)} --> {format_time(end)}\n") + f.write(f"{seg['text'].strip()}\n\n") + current_time = end + return True + except Exception as e: + print(f"Failed to generate SRT: {e}") + return False diff --git a/kokoro_gui/engine/stats.py b/kokoro_gui/engine/stats.py new file mode 100644 index 0000000..7e1ec35 --- /dev/null +++ b/kokoro_gui/engine/stats.py @@ -0,0 +1,81 @@ +"""Per-engine generation-history tracking that seeds and refines the batch +conversion ETA in `conversion.py`. + +Persisted to `kokoro_engine.STATS_FILE`, read/written qualified through the +`kokoro_engine` module rather than imported as a bare constant - the same +convention `caching.py` uses for `kokoro_engine.CACHE_DIR` - so +`tests/conftest.py`'s `isolated_dirs` fixture can monkeypatch it into a +tmp_path and keep tests from writing a real `generation_stats.json` into the +repo working directory. + +Keyed by `engine_id` (Kokoro/Dummy/Audio8 have wildly different chars/sec +throughput - Audio8 in particular serializes every chunk through one shared +model lock, per `kokoro_gui/engines/audio8_tts.py`) so switching backends +never blends one engine's speed into another's estimate. Each engine keeps a +bounded rolling window (`HISTORY_LIMIT`) of its most recent generations +rather than a lifetime average, so the estimate tracks changes in +hardware/settings/thread count instead of being anchored by a stale run. +""" +from __future__ import annotations + +import json +import os +import threading +from typing import Optional + +import kokoro_engine + +HISTORY_LIMIT = 20 # most-recent completed generations kept, per engine + +_lock = threading.Lock() + + +def _load_all() -> dict: + path = kokoro_engine.STATS_FILE + if not os.path.exists(path): + return {} + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _save_all(data: dict) -> None: + try: + with open(kokoro_engine.STATS_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + except Exception as e: + print(f"Failed to save generation stats: {e}") + + +def record_generation(engine_id: str, chars: int, words: int, duration: float) -> None: + """Append one generation's (chars, words, duration) under `engine_id`, + trimming to the most recent `HISTORY_LIMIT` entries. No-ops on + degenerate input (nothing processed, or non-positive duration) so an + instant cancel/failure can't poison the rate estimate.""" + if chars <= 0 or duration <= 0: + return + engine_id = engine_id or "unknown" + with _lock: + data = _load_all() + entries = data.get(engine_id, []) + entries.append({"chars": chars, "words": words, "duration": duration}) + data[engine_id] = entries[-HISTORY_LIMIT:] + _save_all(data) + + +def estimate_chars_per_sec(engine_id: str) -> Optional[float]: + """Recent chars/sec throughput for `engine_id`, or None with no history + yet. Sums chars and duration across the retained window and divides once + (rather than averaging each entry's own rate), so a handful of short + generations can't outvote one long, more representative one.""" + engine_id = engine_id or "unknown" + with _lock: + entries = _load_all().get(engine_id, []) + total_chars = sum(e.get("chars", 0) for e in entries) + total_duration = sum(e.get("duration", 0) for e in entries) + if total_chars <= 0 or total_duration <= 0: + return None + return total_chars / total_duration diff --git a/kokoro_gui/engine/text_extraction.py b/kokoro_gui/engine/text_extraction.py new file mode 100644 index 0000000..54a6c6f --- /dev/null +++ b/kokoro_gui/engine/text_extraction.py @@ -0,0 +1,151 @@ +"""Text extraction from source files (.txt/.pdf/.epub), multi-speaker script +parsing, and long-text splitting into synthesis-sized chunks. + +`extract_text_from_file` reads `pypdf`/`ebooklib`/`epub` via `kokoro_engine.pypdf` +/`.ebooklib`/`.epub` (qualified, at call time) rather than importing those names +directly, so that tests can keep monkeypatching them on the `kokoro_engine` module +(e.g. `monkeypatch.setattr(kokoro_engine.pypdf, "PdfReader", FakeReader)`). +""" +import os +import re +from typing import NamedTuple, Optional + +from bs4 import BeautifulSoup + +import kokoro_engine + +# Same tag syntax `TextExtractionMixin.parse_multispeaker_text` matches - +# duplicated here deliberately rather than shared/refactored out of that +# method, so `find_character_fx_spans` below can never accidentally change +# what conversion.py/jit.py (parse_multispeaker_text's only callers) see. +_SPEAKER_FX_TAG_PATTERN = r"\[([^\]\n]{1,100})\]:\s*" + + +class InlineTagSpan(NamedTuple): + """One `[Name]:`/`[Name:FX]:`-tagged run, with real (unstripped) offsets + into the original text - unlike `parse_multispeaker_text`'s tuples, + which discard offsets and strip/filter the segment text. Used by the + Qt transcript editor's syntax highlighter (kokoro_gui/qt/transcript_editor.py), + which needs exact `QTextDocument` character positions, not cleaned-up text. + """ + + start: int + end: int + speaker_name: str + fx_name: Optional[str] + + +def find_character_fx_spans(text: str) -> list: + """Offset-preserving sibling of `TextExtractionMixin.parse_multispeaker_text` + for `[Name]:`/`[Name:FX]:` tags. Returns `[]` for tagless text (not + `parse_multispeaker_text`'s `[(None, None, text)]` sentinel - a + highlighter has nothing to paint when there's no tag at all). Each + `InlineTagSpan` covers from its tag's own start through the character + just before the next tag (or end of text) - the whole `[Name]: spoken + text` run, unstripped, so it maps 1:1 onto document character positions. + + Module-level rather than a `TextExtractionMixin` method: this is a pure + text-in/data-out utility with no need for a live engine instance. + """ + matches = list(re.finditer(_SPEAKER_FX_TAG_PATTERN, text)) + spans = [] + for i, match in enumerate(matches): + raw_name = match.group(1) + speaker_name, fx_name = raw_name, None + if ":" in raw_name: + parts = raw_name.split(":", 1) + speaker_name = parts[0].strip() + fx_name = parts[1].strip() + + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + spans.append(InlineTagSpan(start=match.start(), end=end, speaker_name=speaker_name, fx_name=fx_name)) + return spans + + +class TextExtractionMixin: + def extract_text_from_file(self, fpath): + if not os.path.exists(fpath): + raise FileNotFoundError("File does not exist.") + + text_data = "" + lower_path = fpath.lower() + + if lower_path.endswith(".pdf"): + reader = kokoro_engine.pypdf.PdfReader(fpath) + for page in reader.pages: + extracted = page.extract_text() + if extracted: + text_data += extracted + "\n\n" + + elif lower_path.endswith(".epub"): + book = kokoro_engine.epub.read_epub(fpath, options={'ignore_ncx': True}) + for item in book.get_items(): + if item.get_type() == kokoro_engine.ebooklib.ITEM_DOCUMENT: + soup = BeautifulSoup(item.get_content(), 'html.parser') + text_data += soup.get_text(separator='\n\n') + "\n\n" + else: + # Assume text based + with open(fpath, "r", encoding="utf-8") as f: + text_data = f.read() + + return text_data + + def parse_multispeaker_text(self, text): + """ + Parses text for [PresetName]: or [PresetName:FXPresetName]: syntax. + Returns a list of (speaker_name, fx_name, text_segment) + """ + # Regex to find [Name]: or [Name:FX]: + + pattern = r"\[([^\]\n]{1,100})\]:\s*" + matches = list(re.finditer(pattern, text)) + + if not matches: + return [(None, None, text)] + + segments = [] + for i in range(len(matches)): + raw_name = matches[i].group(1) + speaker_name = raw_name + fx_name = None + + if ":" in raw_name: + parts = raw_name.split(":", 1) + speaker_name = parts[0].strip() + fx_name = parts[1].strip() + + start = matches[i].end() + end = matches[i+1].start() if i+1 < len(matches) else len(text) + segment_text = text[start:end].strip() + if segment_text: + segments.append((speaker_name, fx_name, segment_text)) + + return segments + + def smart_split(self, text, chunk_size=3000): + chunks = [] + current_chunk = [] + current_len = 0 + paragraphs = text.split('\n\n') + + for para in paragraphs: + if len(para) > chunk_size: + lines = para.split('\n') + for line in lines: + if current_len + len(line) > chunk_size and current_chunk: + chunks.append("\n".join(current_chunk)) + current_chunk = [] + current_len = 0 + current_chunk.append(line) + current_len += len(line) + else: + if current_len + len(para) > chunk_size and current_chunk: + chunks.append("\n\n".join(current_chunk)) + current_chunk = [] + current_len = 0 + current_chunk.append(para) + current_len += len(para) + + if current_chunk: + chunks.append("\n\n".join(current_chunk)) + return [c for c in chunks if c.strip()] diff --git a/kokoro_gui/engine/time_utils.py b/kokoro_gui/engine/time_utils.py new file mode 100644 index 0000000..72831a2 --- /dev/null +++ b/kokoro_gui/engine/time_utils.py @@ -0,0 +1,24 @@ +"""Duration formatting shared by the engine's ETA calculation +(`conversion.py`'s `on_chunk_progress`) and the Qt status bar +(`kokoro_gui/qt/app.py`'s `on_engine_progress`). + +Both call sites used to format with `time.strftime('%M:%S', time.gmtime(seconds))`. +`time.gmtime` turns a seconds count into a full `struct_time` (days/hours/ +minutes/seconds), but `%M` only ever prints `minutes % 60` - once `seconds` +passed 3600 the hours field kept climbing invisibly while the displayed +minutes wrapped back through 00, which read as the elapsed/ETA clock +"resetting" every hour. `format_duration` grows into `H:MM:SS` instead of +wrapping. +""" +from __future__ import annotations + + +def format_duration(seconds: float) -> str: + """Format a duration in seconds as `MM:SS`, or `H:MM:SS` once it reaches + an hour. Negative input is clamped to 0.""" + total_seconds = max(0, int(seconds)) + hours, remainder = divmod(total_seconds, 3600) + minutes, secs = divmod(remainder, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes:02d}:{secs:02d}" diff --git a/kokoro_gui/engine/voices.py b/kokoro_gui/engine/voices.py new file mode 100644 index 0000000..95bd8e2 --- /dev/null +++ b/kokoro_gui/engine/voices.py @@ -0,0 +1,102 @@ +"""Custom-voice path resolution and voice-tensor mixing. + +Reads `kokoro_engine.CUSTOM_VOICES_DIR` and calls `kokoro_engine.get_thread_pipeline` +qualified, at call time, so tests can keep monkeypatching those names on the +`kokoro_engine` module (e.g. via the `isolated_dirs` fixture). +""" +import asyncio +import os + +import torch + +import kokoro_engine + + +def project_voice_dir(project_dir): + """Where a `.tbaw` project keeps the custom mixes it bundles + (`engines/kokoro/voices/`, see Claude/old/PLAN_tbaw_bundle.md section 4).""" + return os.path.join(project_dir, "engines", "kokoro", "voices") + + +class VoiceMixingMixin: + def resolve_voice_path(self, voice_name, project_dir=None): + """ + Returns the absolute path if it's a custom voice, + otherwise returns the name as-is (for standard voices). + A project-local mix (`/engines/kokoro/voices/.pt`) + shadows the global `custom_voices/.pt` (grill TB3). + """ + # Sanitize voice_name to prevent path traversal + safe_voice_name = os.path.basename(voice_name) + search_dirs = [kokoro_engine.CUSTOM_VOICES_DIR] + if project_dir: + search_dirs.insert(0, project_voice_dir(project_dir)) + for directory in search_dirs: + custom_path = os.path.join(directory, f"{safe_voice_name}.pt") + if os.path.exists(custom_path): + return os.path.abspath(custom_path) + # Not a custom voice: return the sanitized name (not the raw + # `voice_name`) so a preset-supplied path/UNC string can't reach + # `KPipeline`/torch.load as a literal path (see Claude/SECURITY_AUDIT.md). + # Standard voice names (e.g. "af_bella") have no path separators, so + # this is a no-op for legitimate names. + return safe_voice_name + + async def mix_voices(self, v1_name, v2_name, ratio, new_name, op='mix'): + def _mix(): + try: + # Ensure we have a pipeline to load voices + # Use 'a' as default for mixing if main pipeline is not ready + p = self.pipeline + if not p: + p = kokoro_engine.get_thread_pipeline('a') + if not p: raise RuntimeError("No pipeline available for mixing") + + # Resolve inputs (handle custom vs standard) + v1_arg = self.resolve_voice_path(v1_name) + v2_arg = self.resolve_voice_path(v2_name) + + # Load tensors + # KPipeline.load_voice returns a tensor + t1 = p.load_voice(v1_arg) + t2 = p.load_voice(v2_arg) + + if t1 is None or t2 is None: + raise ValueError("Failed to load one of the voices.") + + # Ensure they are on CPU for mixing + if isinstance(t1, torch.Tensor): t1 = t1.cpu() + if isinstance(t2, torch.Tensor): t2 = t2.cpu() + + # Check shapes + if t1.shape != t2.shape: + # Try to align? Usually kokoro voices are fixed size [510, 1, 256] + # If different, we might fail or warn. + print(f"Warning: Voice shapes differ {t1.shape} vs {t2.shape}. Mixing might fail or produce garbage.") + + # Apply operation + if op == 'add': + mixed = t1 + t2 * ratio + elif op == 'subtract': + mixed = t1 - t2 * ratio + elif op == 'multiply': + # Lerp between t1 and t1*t2 + mixed = t1 * (1.0 - ratio) + (t1 * t2) * ratio + elif op == 'divide': + # Lerp between t1 and t1/t2 + mixed = t1 * (1.0 - ratio) + (t1 / (t2 + 1e-6)) * ratio + else: # Default: mix (Linear Interpolation) + # mixed = v1 * (1 - ratio) + v2 * ratio + # ratio is mix of B. If ratio 0, full A. If ratio 1, full B. + mixed = t1 * (1.0 - ratio) + t2 * ratio + + # Save + # Sanitize new_name to prevent path traversal + safe_new_name = os.path.basename(new_name) + out_path = os.path.join(kokoro_engine.CUSTOM_VOICES_DIR, f"{safe_new_name}.pt") + torch.save(mixed, out_path) + return True, out_path, mixed + except Exception as e: + return False, str(e), None + + return await asyncio.to_thread(_mix) diff --git a/kokoro_gui/engines/__init__.py b/kokoro_gui/engines/__init__.py new file mode 100644 index 0000000..7cd351f --- /dev/null +++ b/kokoro_gui/engines/__init__.py @@ -0,0 +1,11 @@ +"""Engine backend abstraction (PLAN_qt_and_engine_abstraction.md workstream 1). + +Importing this package registers the built-in "kokoro", "dummy", and +"audio8" backends as a side effect (the submodule imports below). +""" +from kokoro_gui.engines import base, registry +from kokoro_gui.engines.audio8_tts import Audio8BackendAdapter +from kokoro_gui.engines.dummy import DummyBackendAdapter +from kokoro_gui.engines.kokoro import KokoroBackendAdapter + +__all__ = ["base", "registry", "KokoroBackendAdapter", "DummyBackendAdapter", "Audio8BackendAdapter"] diff --git a/kokoro_gui/engines/audio8_tts.py b/kokoro_gui/engines/audio8_tts.py new file mode 100644 index 0000000..528d6c7 --- /dev/null +++ b/kokoro_gui/engines/audio8_tts.py @@ -0,0 +1,671 @@ +"""A real (non-Kokoro) second backend: +https://huggingface.co/Audio8/Audio8-TTS-Preview-0.6b - a 0.6B DualAR +zero-shot voice-cloning model. Unlike Kokoro's named/mixed `.pt` voice +embeddings, Audio8 clones a voice from a *reference WAV + a transcript of +what's said in that WAV* (`capabilities.supports_voice_cloning=True` - see +kokoro_gui/qt/docks/voice_clone_dock.py, the "Voice Reference" tab shown +only for a backend with this capability). + +Follows the `dummy.py`/`kokoro.py` contract (TTSEngineBackend, base.py) and +reuses the same model-agnostic mixins `DummyEngine` does +(AudioFXMixin/ConversionMixin/JITMixin/LexiconMixin/PresetsMixin/SrtMixin/ +TextExtractionMixin from kokoro_gui/engine/__init__.py) - but two things are +genuinely different from both existing backends, both explained where they +happen below: + +1. Output is 44.1kHz, not Kokoro/Dummy's 24000Hz. `ConversionMixin` reads an + instance `self.SAMPLE_RATE` (defaulting to 24000 via `getattr` when a + backend doesn't set one) instead of a hardcoded literal, specifically so + this engine can override it - see kokoro_gui/engine/conversion.py. +2. `get_thread_pipeline` does *not* hand out one model per worker thread the + way Kokoro's `KPipeline` does - see `_Audio8Pipeline` and `_get_model` + below for why and how the model is shared instead. + +`transformers` itself is only imported inside `_get_model()`, not at this +module's top level - though it's already an indirect hard dependency of +this app regardless (the `kokoro` package imports it internally), so that +isn't actually deferring much on its own. What genuinely stays deferred +until `init_pipeline_async`/first generation: `AutoModel.from_pretrained(...)` +actually running - the network fetch (first run) and the model weights +landing in memory - so a user who never switches to this engine never pays +that cost merely by the `kokoro_gui.engines` package registering it at +startup. Loads with `trust_remote_code=True` (the model ships custom +modeling code in its HF repo) - see this module's sibling +kokoro_gui/engine/asr.py for the same note about what that means. +""" +from __future__ import annotations + +import asyncio +import os +import re +import shutil +import threading +from typing import Optional + +import numpy as np + +from kokoro_engine import AsyncLoopThread +from kokoro_gui.engine import ( + AudioFXMixin, CachingMixin, ConversionMixin, JITMixin, LexiconMixin, PresetsMixin, + SrtMixin, TextExtractionMixin, +) +from kokoro_gui.engine.caching import voice_fingerprint +from kokoro_gui.engines.base import ( + BackendHooksMixin, ConfigField, ConfigFieldType, EngineCapabilities, VoiceInfo, + COMMON_SPLIT_PATTERN_CHOICES, COMMON_OUTPUT_FORMAT_CHOICES, bundle_asset_for, +) +from kokoro_gui.engines.registry import register_engine + +TTS_MODEL_ID = "Audio8/Audio8-TTS-Preview-0.6b" +SAMPLE_RATE = 44100 + +# Where a `.tbaw` project keeps the references it bundles (relative to the +# project dir; Claude/old/PLAN_tbaw_bundle.md section 2). +PROJECT_REFS_SUBDIR = "engines/audio8/refs" + +# Saved wav+transcript voice references live as sidecar file pairs here: +# /.wav and /.txt. Mirrors +# kokoro_engine.CUSTOM_VOICES_DIR's flat, name-keyed convention, just with +# two files per entry instead of one .pt. Read qualified (module-global, not +# rebound to a local default) so tests can monkeypatch +# `kokoro_gui.engines.audio8_tts.AUDIO8_REFS_DIR` the same way +# `isolated_dirs` monkeypatches `kokoro_engine.CUSTOM_VOICES_DIR`. +AUDIO8_REFS_DIR = os.path.join("custom_voices", "audio8_refs") + + +def _ref_codes_cache_dir() -> str: + """Persisted cache of *encoded* reference audio ("reference codes" - see + `Audio8Engine._reference_codes_path`/module docstring below): one `.npy` + per distinct reference wav's content, named by its `voice_fingerprint` + (sha256-based, mtime-cached) rather than by reference name, so re-saving + a reference under a new name (or two references sharing identical + audio) reuses the same cache entry, and re-saving one *name* with + different audio correctly misses. Gated by the "cache_reference_codes" + config field (Audio8BackendAdapter.get_config_schema) - default on. Like + `CACHE_DIR`, this grows unbounded; no eviction policy yet (ROADMAP). + + Nested under the *current* `AUDIO8_REFS_DIR`, resolved fresh on every + call (not baked in as a module-level constant at import time) so that + tests monkeypatching `AUDIO8_REFS_DIR` (see `isolated_audio8_refs` in + tests/test_engines_audio8.py) redirect this cache too, the same way + they already redirect reference wav/transcript storage - otherwise this + would keep writing into the real `custom_voices/audio8_refs/` regardless + of that patch. + """ + return os.path.join(AUDIO8_REFS_DIR, ".ref_codes_cache") + +# The model's supported languages (per its model card) - passed through as +# plain strings to whatever `language=` argument the processor expects. +# There's no published short-code table for this model the way Kokoro has +# single-letter lang codes, so the value *is* the display label; worth +# double-checking against the processor's actual accepted values on first +# real run. +AUDIO8_LANGUAGE_CHOICES = [ + (name, name) for name in ( + "English", "Chinese", "Cantonese", "French", "German", "Italian", + "Japanese", "Korean", "Dutch", "Polish", "Spanish", + ) +] + + +class Audio8ReferenceStore: + """CRUD over the saved wav+transcript voice-reference pairs under + `AUDIO8_REFS_DIR`. Plain functions, not a mixin - unlike custom-voice + resolution (which needs a live pipeline to load a `.pt` tensor through), + saving/listing/deleting these sidecar files needs no model, so there's + no reason to route it through `Audio8Engine`. + + Reads take `project_dir`: a reference in the open project's + `engines/audio8/refs/` shadows the global one of the same name (grill + TB3). Writes (`save_reference`, `delete_reference`) always go to the + global store; nothing writes into a project dir except Open's + extraction. Transcripts are cached by `(path, mtime)` so the dirty check, + which reads them through `cache_key_extra`, costs a stat per clip.""" + + _transcript_cache: dict = {} + + @staticmethod + def _safe_name(name: str) -> str: + # Same path-traversal guard as VoiceMixingMixin.resolve_voice_path/ + # mix_voices (kokoro_gui/engine/voices.py). + return os.path.basename(name) + + @staticmethod + def search_dirs(project_dir: Optional[str] = None) -> list: + dirs = [] + if project_dir: + dirs.append(os.path.join(project_dir, *PROJECT_REFS_SUBDIR.split("/"))) + dirs.append(AUDIO8_REFS_DIR) + return dirs + + @staticmethod + def find_wav(name: str, project_dir: Optional[str] = None) -> Optional[str]: + """Absolute path of `.wav`, project-local first, or `None`.""" + safe_name = Audio8ReferenceStore._safe_name(name) + if not safe_name: + return None + for directory in Audio8ReferenceStore.search_dirs(project_dir): + path = os.path.join(directory, f"{safe_name}.wav") + if os.path.isfile(path): + return os.path.abspath(path) + return None + + @staticmethod + def read_transcript_file(txt_path: str) -> str: + """The stripped text of `txt_path`, memoized on the file's + `(mtime, size)` (size too, so a rewrite inside one mtime tick still + misses); `""` when the file is missing.""" + try: + stat = os.stat(txt_path) + except OSError: + return "" + stamp = (stat.st_mtime, stat.st_size) + cached = Audio8ReferenceStore._transcript_cache.get(txt_path) + if cached is not None and cached[0] == stamp: + return cached[1] + try: + with open(txt_path, "r", encoding="utf-8") as f: + text = f.read().strip() + except OSError: + return "" + Audio8ReferenceStore._transcript_cache[txt_path] = (stamp, text) + return text + + @staticmethod + def save_reference(name: str, wav_path: str, transcript: str) -> str: + """Copies `wav_path` and writes `transcript` under a sanitized + `name`, creating `AUDIO8_REFS_DIR` if needed. Returns the saved wav's + absolute path.""" + safe_name = Audio8ReferenceStore._safe_name(name) + if not safe_name: + raise ValueError("Reference name must not be empty.") + os.makedirs(AUDIO8_REFS_DIR, exist_ok=True) + out_wav = os.path.join(AUDIO8_REFS_DIR, f"{safe_name}.wav") + out_txt = os.path.join(AUDIO8_REFS_DIR, f"{safe_name}.txt") + shutil.copyfile(wav_path, out_wav) + with open(out_txt, "w", encoding="utf-8") as f: + f.write(transcript.strip()) + return os.path.abspath(out_wav) + + @staticmethod + def list_references(project_dir: Optional[str] = None) -> list: + """Returns sorted `[name, ...]` for every wav+txt sidecar pair found + in the project dir or the global store (a lone `.wav` or `.txt` + without its partner is skipped - an incomplete/interrupted save, not + a usable reference).""" + names = set() + for directory in Audio8ReferenceStore.search_dirs(project_dir): + if not os.path.isdir(directory): + continue + for f in os.listdir(directory): + if not f.endswith(".wav"): + continue + name = f[:-4] + if os.path.isfile(os.path.join(directory, f"{name}.txt")): + names.add(name) + return sorted(names) + + @staticmethod + def get_transcript(name: str, project_dir: Optional[str] = None) -> str: + wav = Audio8ReferenceStore.find_wav(name, project_dir) + if wav is None: + return "" + return Audio8ReferenceStore.read_transcript_file(os.path.splitext(wav)[0] + ".txt") + + @staticmethod + def delete_reference(name: str) -> None: + safe_name = Audio8ReferenceStore._safe_name(name) + for ext in (".wav", ".txt"): + path = os.path.join(AUDIO8_REFS_DIR, f"{safe_name}{ext}") + if os.path.exists(path): + os.remove(path) + + +# --- Shared model singleton ------------------------------------------------- +# +# A 0.6B-parameter model loaded once per worker thread (Kokoro's KPipeline +# convention) would multiply GPU/RAM use by `num_threads` for zero benefit - +# unlike KPipeline, nothing about loading this model is thread-specific. +# Instead it's loaded once, process-wide, and every thread's generation call +# is serialized through `_model_lock` - safe (no concurrent `.generate()` +# calls into one model instance) at the cost of chunk generation itself not +# actually parallelizing across `num_threads` (I/O and pre/post-processing +# still overlap). See `Audio8BackendAdapter.get_config_schema`'s lower +# `num_threads` max, which reflects that. +_model_lock = threading.Lock() +_model = None +_processor = None + + +def _get_model(): + global _model, _processor + with _model_lock: + if _model is not None: + return _model, _processor + try: + from transformers import AutoModel, AutoProcessor + except ImportError as e: + raise RuntimeError( + "The Audio8 engine needs the 'transformers' package " + "(pip install -r requirements.txt)." + ) from e + try: + processor = AutoProcessor.from_pretrained(TTS_MODEL_ID, trust_remote_code=True) + model = AutoModel.from_pretrained(TTS_MODEL_ID, trust_remote_code=True) + except Exception as e: + raise RuntimeError(f"Failed to load {TTS_MODEL_ID}: {e}") from e + _model, _processor = model, processor + return _model, _processor + + +class _Audio8Pipeline: + """Presents the shared singleton model as a `kokoro.KPipeline`-shaped + callable-generator (`pipeline(text, voice=, speed=, split_pattern=)` -> + `(graphemes, phonemes, audio)` triples, `audio` mono float32 at + `SAMPLE_RATE`), the same convention `DummyPipeline` mimics + (kokoro_gui/engines/dummy.py), so this engine's own `process_chunk_task` + and the generic `ConversionMixin.generate_preview`/`smart_combine` can + all drive it uniformly. + + `voice` here is always an already-*resolved* reference wav path (by the + time any of the generic mixins call a pipeline, `config['voice']` has + already been run through `Audio8Engine.resolve_voice_path` - see + `ConversionMixin.start_conversion`/`generate_preview`) - the matching + transcript sidecar is looked up from that path here, once per call, + rather than needing a separate "voice name" threaded through everywhere. + """ + + def __init__(self, engine: "Audio8Engine", lang_code: str = "English"): + self._engine = engine + self.lang_code = lang_code + + def __call__(self, text, voice=None, speed=1.0, split_pattern=r"\n+"): + try: + segments = [s.strip() for s in re.split(split_pattern, text) if s.strip()] + except re.error: + segments = [] + if not segments and text.strip(): + segments = [text.strip()] + + ref_transcript = self._engine.resolve_voice_transcript(voice) + for seg in segments: + audio = self._engine.generate_segment(seg, voice, ref_transcript, speed, self.lang_code) + yield seg, "", audio + + +class Audio8Engine( + AudioFXMixin, CachingMixin, ConversionMixin, JITMixin, LexiconMixin, PresetsMixin, + SrtMixin, TextExtractionMixin, +): + """KokoroEngine-shaped enough for the GUI to drive directly - same + required surface as `DummyEngine` (worker/cancel_event/pipeline/ + on_progress/on_status/on_finish/init_pipeline_async/get_thread_pipeline/ + resolve_voice_path/cancel), plus `SAMPLE_RATE=44100`, which + `CachingMixin.process_chunk_task` reads instead of assuming 24000, and + the two segment-key hooks that differ from Kokoro: `engine_version` (the + model id) and `cache_key_extra` (reference transcript + sampling knobs).""" + + id = "audio8" + SAMPLE_RATE = SAMPLE_RATE + + def __init__(self): + self.worker = AsyncLoopThread() + self.worker.start() + self.cancel_event = threading.Event() + self.pipeline = False # not ready until init_pipeline_async loads the model + + self.on_progress = None + self.on_status = None + self.on_finish = None + + # Whether `generate_segment` should reuse a persisted, content-keyed + # encoding of the reference wav instead of re-running the model's + # audio encoder on every segment (see `_reference_codes_path`). + # `process_chunk_task` overwrites this from `config['cache_reference_codes']` + # each run - the `True` here only matters for callers that skip + # `process_chunk_task` (e.g. calling `generate_segment` directly). + self.cache_reference_codes = True + + # `ArkttsModel.generate`/`generate_audio` sampling knobs, exposed as + # config fields (Audio8BackendAdapter.get_config_schema, "Generation" + # group) rather than hardcoded - `process_chunk_task` overwrites + # these from `config` each run, same pattern as `cache_reference_codes` + # above. Defaults match this engine's original hardcoded values, + # except `max_new_tokens` (was 4096, clamped internally to whatever + # room is left under the model's `max_seq_len=2048` anyway - 1024 + # is a more honest default that still leaves prompt room). + self.max_new_tokens = 1024 + self.temperature = 0.8 + self.top_p = 0.95 + self.top_k = 50 + + self._lexicon_cache = {} + # Project dir whose bundled references were last encoded into the + # ref-codes cache (see `warm_reference_codes`). + self._warmed_project_dir = None + + os.makedirs(AUDIO8_REFS_DIR, exist_ok=True) + + async def init_pipeline_async(self, lang_code="a", device=None): + if self.on_status: + self.on_status("Loading Audio8 TTS model (first use downloads it)...", False) + try: + await asyncio.to_thread(_get_model) + except Exception as e: + self.pipeline = False + if self.on_status: + self.on_status(f"Audio8 model load failed: {e}", True) + return False + self.pipeline = True + if self.on_status: + self.on_status("Audio8 TTS ready.", False) + return True + + def get_thread_pipeline(self, lang_code="English"): + return _Audio8Pipeline(self, lang_code) + + # -- segment_key hooks (see kokoro_gui/engine/caching.py) -------------- + + def engine_version(self) -> str: + """The model id, not a package version: `transformers` is the + package and its version says nothing about these weights.""" + return TTS_MODEL_ID + + def cache_key_extra(self, config: dict) -> dict: + """The reference transcript (the same wav with a corrected + transcript generates differently) and the sampling knobs. Read from + `config`, with the schema defaults, so the dirty check and the + engine see one set of values.""" + ref_wav = config.get("voice") + if ref_wav and not (os.path.isabs(ref_wav) and os.path.isfile(ref_wav)): + ref_wav = self.resolve_voice_path(ref_wav, config.get("project_dir")) + return { + "ref_transcript": self.resolve_voice_transcript(ref_wav), + "max_new_tokens": config.get("max_new_tokens", 1024), + "temperature": config.get("temperature", 0.8), + "top_p": config.get("top_p", 0.95), + "top_k": config.get("top_k", 50), + } + + def warm_reference_codes(self, project_dir: Optional[str]) -> None: + """Encodes every reference the project carries into + `_ref_codes_cache_dir()` if it isn't there yet. Called from the + first generate after a project opens (`process_chunk_task`), when + the model is loaded anyway; never from `on_project_opened`.""" + if not project_dir or not self.cache_reference_codes: + return + refs_dir = os.path.join(project_dir, *PROJECT_REFS_SUBDIR.split("/")) + if not os.path.isdir(refs_dir): + return + for f in os.listdir(refs_dir): + if f.endswith(".wav"): + wav = os.path.abspath(os.path.join(refs_dir, f)) + self._reference_codes_path(wav, self.resolve_voice_transcript(wav)) + + def resolve_voice_path(self, voice_name: str, project_dir: Optional[str] = None) -> str: + """Resolves a saved reference name to its absolute wav path + (sanitized-basename convention, matching + `VoiceMixingMixin.resolve_voice_path`), project-local first. Falls back to treating + `voice_name` as a literal existing file path (a wav dropped straight + into the Voice Reference dock and generated with before ever being + saved under a name), and finally to returning it unchanged (will + fail clearly at generation time rather than silently). Tolerates a + falsy `voice_name` (e.g. the Voice dropdown is empty because no + reference has been saved yet) by returning it as-is rather than + raising here - the resulting generation failure is reported through + the normal per-chunk error path (`_process_text_async`'s + `asyncio.gather(..., return_exceptions=True)`) instead of crashing + synchronously on the Qt main thread inside `start_conversion`.""" + if not voice_name: + return voice_name + safe_name = os.path.basename(voice_name) + saved_path = Audio8ReferenceStore.find_wav(safe_name, project_dir) + if saved_path: + return saved_path + if os.path.isabs(voice_name) and os.path.isfile(voice_name): + return voice_name + # Neither a saved reference nor an existing absolute file: return the + # sanitized basename, not the raw string, so a preset-supplied + # relative/UNC path can't be used as a literal path downstream (same + # traversal fix as VoiceMixingMixin.resolve_voice_path - see + # Claude/SECURITY_AUDIT.md). This still "fails clearly at generation + # time" per the docstring above, just without ever touching the + # unsanitized string first. + return safe_name + + def resolve_voice_transcript(self, resolved_voice_path: str) -> str: + """Given an already-*resolved* reference wav path (see + `resolve_voice_path`), returns the transcript from its sidecar + `.txt` file (same base name, `.wav` -> `.txt`), or `""` if none + exists (including when `resolved_voice_path` itself is falsy).""" + if not resolved_voice_path: + return "" + return Audio8ReferenceStore.read_transcript_file(os.path.splitext(resolved_voice_path)[0] + ".txt") + + def _reference_codes_path(self, ref_wav_path: str, ref_transcript: str) -> Optional[str]: + """Returns the path to a persisted `.npy` of `ref_wav_path`'s + *encoded* reference ("reference codes" - `ArkttsModel.encode_audio`'s + output), computing and caching it on first use under + `_ref_codes_cache_dir()`. Returns `None` when caching isn't + applicable (no on-disk wav to fingerprint, no transcript to run the + one-off encode with) or if the encode itself fails - the caller + falls back to passing raw `reference_audio` on every call in that + case, exactly like before this cache existed. + + This is the one genuine "reference audio -> tensor" step: the model + encodes `reference_audio_values` into `reference_codes` via its own + audio codec (`ArkttsModel.encode_audio`, a real forward pass through + `ArkttsCodec` - not free) inside `_prepare_prompt` on *every* + `generate`/`generate_audio` call that's given raw audio. Passing + `reference_codes=` instead (which `ArkttsProcessor.__call__` accepts + as a path it `np.load`s itself, per `processing_arktts.py`) skips + that re-encode entirely - the same reference wav produces identical + codes every time, so encoding it once and reusing the codes across + every segment/chunk that shares a voice reference is a correctness- + preserving cache, not an approximation. + """ + if not ref_wav_path: + return None + fp = voice_fingerprint(ref_wav_path) + if fp == ref_wav_path: + return None # not an existing absolute file - can't fingerprint/cache it + cache_dir = _ref_codes_cache_dir() + cache_path = os.path.join(cache_dir, f"{fp}.npy") + if os.path.isfile(cache_path): + return cache_path + if not ref_transcript: + return None # a reference-conditioned encode requires reference_text too + + try: + model, processor = _get_model() + with _model_lock: + probe = processor( + text="x", reference_audio=ref_wav_path, reference_text=ref_transcript, + return_tensors="pt", + ) + codes, code_lengths = model.encode_audio( + probe["reference_audio_values"], probe["reference_audio_lengths"], + ) + trimmed = codes[0, :, : int(code_lengths[0])].detach().cpu().numpy().astype(np.int64) + os.makedirs(cache_dir, exist_ok=True) + np.save(cache_path, trimmed) + except Exception as e: + print(f"Audio8 reference-codes cache write error: {e}") + return None + return cache_path + + def generate_segment(self, text: str, ref_wav_path: str, ref_transcript: str, + speed: float, lang_code: str) -> np.ndarray: + """Runs one segment through the shared model, serialized via + `_model_lock` (see module docstring). Returns mono float32 audio at + `SAMPLE_RATE`. + + Checked against the installed model's actual `processing_arktts.py`/ + `modeling_arktts.py` (the model card guess this originally shipped + with was wrong on every point below): + + - The processor's real kwargs are `reference_audio`/`reference_text`, + not `ref_audio`/`ref_text`. + - Neither `ArkttsProcessor.__call__` nor `ArkttsModel.generate` take + a `language` or `speed` argument at all - both raise `TypeError` + on any kwarg they don't recognize, which is what surfaced as + "Unexpected processor arguments: [...]". `speed`/`lang_code` stay + in this method's signature only so it keeps matching + `_Audio8Pipeline`/`process_chunk_task`'s generic + `(text, voice, speed, lang_code)` shape shared with Kokoro/Dummy - + the model always synthesizes at its own pace and infers language + from the text itself, so both are accepted here and silently + unused rather than forwarded. + - `processor.decode(...)` is just `tokenizer.decode` (text token + decoding) - it was never how to get audio out. The real path is + `model.generate(**inputs)` -> codes -> `model.decode_audio(codes)`, + or the combined `model.generate_audio(**inputs, ...)` used below, + which returns `(waveforms, lengths, codes)` directly. + + When `self.cache_reference_codes` is on (see `process_chunk_task`), + looks up/populates a persisted reference-codes cache first (see + `_reference_codes_path`) and passes `reference_codes=` instead of + `reference_audio=`/`reference_text=` on a hit - same output, skips + re-encoding the reference wav through the model's audio codec. + """ + model, processor = _get_model() + cached_codes_path = ( + self._reference_codes_path(ref_wav_path, ref_transcript) + if self.cache_reference_codes else None + ) + with _model_lock: + if cached_codes_path: + # `reference_text` isn't only an input to the audio encode - + # `ArkttsProcessor._prompt_segments` bakes it into the *text* + # prompt tokens whenever `has_reference` is True (set by + # either `reference_audio` or `reference_codes`), so it's + # still required here even though the audio side is cached. + inputs = processor( + text=text, reference_codes=cached_codes_path, + reference_text=ref_transcript or None, return_tensors="pt", + ) + else: + inputs = processor( + text=text, + reference_audio=ref_wav_path or None, + reference_text=ref_transcript or None, + return_tensors="pt", + ) + waveforms, lengths, _codes = model.generate_audio( + **inputs, max_new_tokens=self.max_new_tokens, temperature=self.temperature, + top_p=self.top_p, top_k=self.top_k, + ) + audio = waveforms[0, : lengths[0]].detach().cpu().numpy() + + audio = np.asarray(audio, dtype=np.float32).reshape(-1) + return audio + + def process_chunk_task(self, chunk_data, progress_callback): + """`CachingMixin.process_chunk_task` with this engine's per-run + state read off `config` first: `cache_reference_codes` and the + sampling knobs `generate_segment` uses (also what `cache_key_extra` + folds into the key, so a stale segment cached under old values + misses rather than serving old audio). The first chunk after a + project opens also warms the reference-codes cache from the + project's own refs (grill TB7, revised: derived data lives in this + machine's cache, so a flag written on another machine is ignored).""" + _index, _text, config = chunk_data + self.cache_reference_codes = config.get('cache_reference_codes', True) + self.max_new_tokens = config.get('max_new_tokens', 1024) + self.temperature = config.get('temperature', 0.8) + self.top_p = config.get('top_p', 0.95) + self.top_k = config.get('top_k', 50) + project_dir = config.get("project_dir") + if project_dir and project_dir != self._warmed_project_dir: + self._warmed_project_dir = project_dir + try: + self.warm_reference_codes(project_dir) + except Exception as e: + print(f"Audio8 reference warm-up skipped: {e}") + return super().process_chunk_task(chunk_data, progress_callback) + + def cancel(self) -> None: + self.cancel_event.set() + + +class Audio8BackendAdapter(BackendHooksMixin): + id = "audio8" + display_name = "Audio8 TTS (voice cloning)" + capabilities = EngineCapabilities( + supports_voice_mixing=False, + supports_voice_cloning=True, + supports_multi_speaker_script=True, + is_local_model=True, + supports_jit_streaming=False, + ) + + def __init__(self, engine: Optional[Audio8Engine] = None): + self._engine = engine if engine is not None else Audio8Engine() + + @property + def engine(self): + return self._engine + + def get_config_schema(self) -> list: + return [ + ConfigField("lang_code", "Language", ConfigFieldType.CHOICE, + default="English", choices=list(AUDIO8_LANGUAGE_CHOICES), group="Generation"), + ConfigField("voice", "Voice Reference", ConfigFieldType.CHOICE, + default=None, group="Generation"), + ConfigField("speed", "Speed", ConfigFieldType.SLIDER, + default=1.0, min=0.5, max=2.0, step=0.1, group="Generation"), + ConfigField("split_pattern", "Split By", ConfigFieldType.CHOICE, + default=r"\n+", choices=list(COMMON_SPLIT_PATTERN_CHOICES), group="Generation"), + ConfigField("format", "Output Format", ConfigFieldType.CHOICE, + default="wav", choices=list(COMMON_OUTPUT_FORMAT_CHOICES), group="Generation"), + ConfigField("num_threads", "Parallel Threads", ConfigFieldType.INT, + default=1, min=1, max=4, step=1, group="Advanced"), + ConfigField("caching", "Enable Segment Cache", ConfigFieldType.BOOL, + default=True, group="Advanced"), + ConfigField("cache_reference_codes", "Cache Reference Encoding", ConfigFieldType.BOOL, + default=True, group="Advanced"), + # `ArkttsModel.generate`/`generate_audio` sampling knobs (see + # `Audio8Engine.__init__`/`process_chunk_task`/`generate_segment`) + # - model-specific, unlike everything above, so broken out into + # their own group rather than folded into "Generation"/"Advanced". + # `max_new_tokens` above `max_seq_len - ` (2048 + # total, per the model's config) is clamped internally by + # `ArkttsModel.generate` - the 2048 ceiling here just matches + # that reality instead of offering a value that's silently capped. + ConfigField("max_new_tokens", "Max New Tokens", ConfigFieldType.INT, + default=1024, min=64, max=2048, step=64, group="Model"), + ConfigField("temperature", "Temperature", ConfigFieldType.SLIDER, + default=0.8, min=0.1, max=2.0, step=0.05, group="Model"), + ConfigField("top_p", "Top P", ConfigFieldType.SLIDER, + default=0.95, min=0.0, max=1.0, step=0.01, group="Model"), + ConfigField("top_k", "Top K", ConfigFieldType.INT, + default=50, min=0, max=200, step=1, group="Model"), + ] + + def get_voices(self, lang_code: Optional[str] = None) -> list: + """Saved wav+transcript references, the open project's first - see + `Audio8ReferenceStore`. Unlike Kokoro, there are no built-in named + voices at all; every selectable "voice" here is a saved reference.""" + return [ + VoiceInfo(id=name, display_name=name, lang_code=None, is_custom=True) + for name in Audio8ReferenceStore.list_references(self.project_dir) + ] + + def collect_project_assets(self, voice_names, project_dir=None) -> tuple: + """The wav + txt pair for every named reference, as + `engines/audio8/refs/.{wav,txt}`. `meta` is empty: the + reference-codes cache is derived data that lives in this machine's + cache and is rebuilt on the first generate after open.""" + assets = [] + for name in sorted(voice_names): + for ext in (".wav", ".txt"): + asset = bundle_asset_for(name, AUDIO8_REFS_DIR, ext, PROJECT_REFS_SUBDIR, project_dir) + if asset is not None: + assets.append(asset) + return assets, {} + + def cancel(self) -> None: + self._engine.cancel() + + +register_engine("audio8", Audio8BackendAdapter, display_name=Audio8BackendAdapter.display_name) diff --git a/kokoro_gui/engines/base.py b/kokoro_gui/engines/base.py new file mode 100644 index 0000000..b853880 --- /dev/null +++ b/kokoro_gui/engines/base.py @@ -0,0 +1,229 @@ +"""Cross-engine surface for TTS backends. + +This is workstream 1 of PLAN_qt_and_engine_abstraction.md ("Abstract the +model-specific parts"): a thin, engine-agnostic description of what a TTS +backend *is* (id, display name, capability flags) and what settings it takes +(`get_config_schema()`), so the GUI can eventually render per-engine panels +and gate engine-specific tabs/features without hard-coding "Kokoro" anywhere. + +Deliberately thin. `KokoroEngine`'s actual generation/streaming entry points +(`start_conversion`, `start_jit_conversion`, `generate_preview`) stay +callback-driven and scheduled onto `AsyncLoopThread` - per the plan's +"Explicitly out of scope" note, that execution model stays Kokoro-backend- +private for now. A uniform async `generate()`/`start_jit()` request/response +surface every backend implements the same way is real design work that's +premature until a second backend actually exists to validate it against +(see migration step 5); inventing it here, unvalidated, is exactly the kind +of over-fit-to-Kokoro abstraction the plan warns against for voice mixing. +So this Protocol only covers what's true for *any* backend today: identity, +capabilities, its config schema, its voice list, and cancellation, plus the +five `.tbaw` hooks (Claude/old/PLAN_tbaw_bundle.md section 5) that +`BackendHooksMixin` gives working defaults for: `engine_version`, +`cache_key_extra`, `resolve_voice_file` (the segment-key trio, forwarded to +the wrapped engine because `process_chunk_task` runs there without an +adapter reference), `collect_project_assets` and `on_project_opened` (adapter +only). Nothing in this package imports `kokoro_gui/daw/`: the project layer +walks the document and hands each backend the voice names it uses. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from enum import Enum +from typing import Any, Optional, Protocol, runtime_checkable + + +class ConfigFieldType(str, Enum): + """Widget shape a GUI should render for a `ConfigField`.""" + FLOAT = "float" + INT = "int" + BOOL = "bool" + TEXT = "text" + FILE = "file" + CHOICE = "choice" + SLIDER = "slider" + + +@dataclass(frozen=True) +class ConfigField: + """One entry in a backend's `get_config_schema()`. + + `choices`, when set, is a list of `(label, value)` pairs for CHOICE/SLIDER + fields with a fixed, known set of options (e.g. split-pattern presets, + output format). Fields whose options are only known at runtime by the GUI + (e.g. "voice", "lang_code" - today's Kokoro voice catalog is GUI display + data, not engine data; see kokoro.py's `get_config_schema` docstring) + leave `choices=None` and the GUI resolves them dynamically. + """ + key: str + label: str + type: ConfigFieldType + default: Any = None + min: Optional[float] = None + max: Optional[float] = None + step: Optional[float] = None + choices: Optional[list] = None + group: str = "General" + + +@dataclass(frozen=True) +class EngineCapabilities: + """Flags the GUI uses to show/hide whole panels rather than special- + casing engine names/ids.""" + supports_voice_mixing: bool = False # show the Mixing tab at all + supports_voice_cloning: bool = False # show an upload-a-sample panel + supports_multi_speaker_script: bool = False # [Speaker:FX]: syntax + is_local_model: bool = True # device/GPU picker vs API-key field + supports_jit_streaming: bool = True + + +# Choice presets for schema fields whose *meaning* isn't actually +# model-specific, just conventionally offered by more than one backend: how +# raw input text gets split into chunks before parallel processing, and +# which container formats get written to disk. Backends are free to ignore +# these or offer their own instead - they're shared defaults, not part of +# the Protocol. +COMMON_SPLIT_PATTERN_CHOICES = [ + ("Natural (Newlines)", r"\n+"), + ("Paragraphs (Double Newline)", r"\n\n+"), + ("Sentences (.!?)", r"(?/...`, forward slashes) and where its bytes are now.""" + bundle_path: str + source_path: str + + +class BackendHooksMixin: + """Working defaults for the `.tbaw` hooks. An adapter that wraps an + engine exposing `engine_version`/`cache_key_extra`/`resolve_voice_file` + (every engine built on `CachingMixin`) forwards to it; otherwise the + key gets the package version for the adapter's id, no extra inputs, and + no voice file. `project_dir` is whatever `on_project_opened` last + recorded, for listings that should show project-local assets first.""" + + project_dir: Optional[str] = None + + def engine_version(self) -> str: + """What goes into the segment key and `manifest.engines[id].version`. + A backend that changes output without a package bump must change + this string.""" + engine = getattr(self, "engine", None) + hook = getattr(engine, "engine_version", None) + if callable(hook): + return hook() + from kokoro_gui.engine.caching import get_engine_version + + return get_engine_version(self.id) + + def cache_key_extra(self, config: dict) -> dict: + """Backend-specific generation inputs folded into `segment_key`.""" + engine = getattr(self, "engine", None) + hook = getattr(engine, "cache_key_extra", None) + return dict(hook(config) or {}) if callable(hook) else {} + + def resolve_voice_file(self, name: str, project_dir: Optional[str] = None) -> Optional[str]: + """The file `name` resolves to, project-local first, or `None` for a + built-in voice.""" + engine = getattr(self, "engine", None) + hook = getattr(engine, "resolve_voice_file", None) + return hook(name, project_dir) if callable(hook) else None + + def collect_project_assets(self, voice_names, project_dir=None) -> tuple: + """`([BundleAsset, ...], meta)`: every file under `engines//` + needed to reproduce the given voice names, plus the opaque `meta` + dict written to `manifest.engines[id]`. A name that resolves to + nothing is skipped (the project still saves; the character still + names it). Default: no files, empty meta.""" + return [], {} + + def on_project_opened(self, project_dir: Optional[str], meta: dict) -> None: + """Called after a project is opened (or created) with this backend's + manifest `meta`. Must not load a model: record what to do and do it + on the first generate. The default remembers the dir for listings.""" + self.project_dir = project_dir + + +def bundle_asset_for(name: str, directory: str, extension: str, bundle_dir: str, + project_dir: Optional[str] = None) -> Optional[BundleAsset]: + """Helper for `collect_project_assets`: `` looked up in + the project-local `bundle_dir` first, then in the global `directory`, + returned as a `BundleAsset` at `/`.""" + safe = os.path.basename(name) + if not safe: + return None + candidates = [] + if project_dir: + candidates.append(os.path.join(project_dir, *bundle_dir.split("/"))) + candidates.append(directory) + for candidate_dir in candidates: + path = os.path.join(candidate_dir, f"{safe}{extension}") + if os.path.isfile(path): + return BundleAsset(f"{bundle_dir}/{safe}{extension}", os.path.abspath(path)) + return None + + +@runtime_checkable +class TTSEngineBackend(Protocol): + id: str + display_name: str + capabilities: EngineCapabilities + + def get_config_schema(self) -> list: + """Return this backend's `ConfigField` list, describing the settings + a generic GUI panel would need to render it.""" + ... + + def get_voices(self, lang_code: Optional[str] = None) -> list: + """Return this backend's known `VoiceInfo` list, optionally filtered + to a language code.""" + ... + + def cancel(self) -> None: + """Cancel any in-flight generation.""" + ... + + # `.tbaw` hooks - see BackendHooksMixin for the defaults and docs. + + def engine_version(self) -> str: + ... + + def cache_key_extra(self, config: dict) -> dict: + ... + + def resolve_voice_file(self, name: str, project_dir: Optional[str] = None) -> Optional[str]: + ... + + def collect_project_assets(self, voice_names, project_dir=None) -> tuple: + ... + + def on_project_opened(self, project_dir: Optional[str], meta: dict) -> None: + ... + + +@runtime_checkable +class SupportsVoiceMixing(Protocol): + """Optional extension for backends whose voices are locally-loadable + tensors that can be blended (`capabilities.supports_voice_mixing=True`). + Not part of `TTSEngineBackend` itself - per the plan, mixing has no + equivalent in a cloud TTS API or a differently-shaped local model, so it + is fenced off as an opt-in capability instead of forced into the shared + protocol.""" + + async def mix_voices(self, v1_name: str, v2_name: str, ratio: float, + new_name: str, op: str = "mix"): + ... diff --git a/kokoro_gui/engines/dummy.py b/kokoro_gui/engines/dummy.py new file mode 100644 index 0000000..3c2760f --- /dev/null +++ b/kokoro_gui/engines/dummy.py @@ -0,0 +1,165 @@ +"""A from-scratch, non-Kokoro backend for exercising the engine-switching UI +without a real model - PLAN_qt_and_engine_abstraction.md workstream 1, step 5: +"consider a second backend (even a stub/fake one) to prove the abstraction +isn't over-fit to Kokoro". + +`DummyEngine` reuses every mixin in `kokoro_gui/engine/` that turned out to be +genuinely model-agnostic (FX, caching, conversion orchestration, JIT +streaming, lexicon, presets, SRT export, text extraction) unmodified, and +only supplies its own `get_thread_pipeline` (a fake generator that yields +short sine-wave tones instead of real speech). `CachingMixin` keys every +entry on `engine_id`, so a dummy tone can't collide with a Kokoro segment +for the same text; its schema still defaults `caching` off, since there's +nothing worth caching. + +No `VoiceMixingMixin` - `capabilities.supports_voice_mixing=False`, so the +Mixing dock is not shown while this backend is active (see the Qt frontend's +`kokoro_gui/qt/app.py`'s `_sync_mixing_dock`), demonstrating that gate +actually works. +""" +from __future__ import annotations + +import re +import threading + +import numpy as np + +from kokoro_engine import AsyncLoopThread +from kokoro_gui.engine import ( + AudioFXMixin, CachingMixin, ConversionMixin, JITMixin, LexiconMixin, PresetsMixin, + SrtMixin, TextExtractionMixin, +) +from kokoro_gui.engines.base import ( + BackendHooksMixin, ConfigField, ConfigFieldType, EngineCapabilities, VoiceInfo, + COMMON_SPLIT_PATTERN_CHOICES, COMMON_OUTPUT_FORMAT_CHOICES, +) +from kokoro_gui.engines.registry import register_engine + +SAMPLE_RATE = 24000 + + +class DummyPipeline: + """Fakes `kokoro.KPipeline`'s callable-generator surface closely enough + for the generic mixins to drive it: `pipeline(text, voice=, speed=, + split_pattern=)` yields `(graphemes, phonemes, audio)` triples, `audio` + a mono float32 ndarray at `SAMPLE_RATE`. No model, no weights, no + eSpeak - a short sine tone stands in for speech, its pitch derived from + the voice name so different "voices" are at least audibly different.""" + + def __init__(self, lang_code="a"): + self.lang_code = lang_code + + def __call__(self, text, voice="dummy", speed=1.0, split_pattern=r"\n+"): + segments = [s.strip() for s in re.split(split_pattern, text) if s.strip()] + if not segments and text.strip(): + segments = [text.strip()] + for seg in segments: + yield seg, "", _tone_for(seg, speed, voice) + + +def _tone_for(text, speed, voice): + duration = max(0.3, min(4.0, len(text) * 0.05 / max(speed, 0.1))) + n = max(1, int(duration * SAMPLE_RATE)) + t = np.linspace(0.0, duration, n, endpoint=False, dtype=np.float32) + freq = 220.0 + (abs(hash(voice)) % 400) # different "voices" -> different pitch + tone = (0.2 * np.sin(2 * np.pi * freq * t)).astype(np.float32) + fade = min(200, n // 4) + if fade > 0: + env = np.ones(n, dtype=np.float32) + env[:fade] = np.linspace(0.0, 1.0, fade, dtype=np.float32) + env[-fade:] = np.linspace(1.0, 0.0, fade, dtype=np.float32) + tone = tone * env + return tone + + +class DummyEngine( + AudioFXMixin, CachingMixin, ConversionMixin, JITMixin, LexiconMixin, PresetsMixin, + SrtMixin, TextExtractionMixin, +): + """KokoroEngine-shaped enough for the GUI to drive directly (same + `worker`/`cancel_event`/`pipeline`/`on_progress`/`on_status`/`on_finish`/ + `start_conversion`/`start_jit_conversion`/`generate_preview`/`cancel` + surface), but with no real synthesis underneath.""" + + id = "dummy" + SAMPLE_RATE = SAMPLE_RATE + + def __init__(self): + self.worker = AsyncLoopThread() + self.worker.start() + self.cancel_event = threading.Event() + self.pipeline = True # no model to load - "ready" immediately + + self.on_progress = None + self.on_status = None + self.on_finish = None + + self._lexicon_cache = {} + + async def init_pipeline_async(self, lang_code="a", device=None): + self.pipeline = True + if self.on_status: + self.on_status(f"Dummy pipeline ready ({lang_code}).", False) + return True + + def get_thread_pipeline(self, lang_code="a"): + return DummyPipeline(lang_code) + + def resolve_voice_path(self, voice_name, project_dir=None): + # No custom-voice directory concept for the dummy backend - voice + # names are just labels that pick a tone pitch (see _tone_for). + return voice_name + + def cancel(self): + self.cancel_event.set() + + +class DummyBackendAdapter(BackendHooksMixin): + id = "dummy" + display_name = "Dummy (offline test tone)" + capabilities = EngineCapabilities( + supports_voice_mixing=False, + supports_voice_cloning=False, + supports_multi_speaker_script=True, + is_local_model=True, + supports_jit_streaming=True, + ) + + def __init__(self, engine=None): + """Same convention as `KokoroBackendAdapter`: wraps an existing + `DummyEngine` when given (tests), otherwise builds its own - used + when the GUI switches its active backend at runtime.""" + self._engine = engine if engine is not None else DummyEngine() + + @property + def engine(self): + return self._engine + + def get_config_schema(self) -> list: + return [ + ConfigField("lang_code", "Language", ConfigFieldType.CHOICE, + default="a", group="Generation"), + ConfigField("voice", "Voice", ConfigFieldType.CHOICE, + default="dummy", group="Generation"), + ConfigField("speed", "Speed", ConfigFieldType.SLIDER, + default=1.0, min=0.5, max=2.0, step=0.1, group="Generation"), + ConfigField("pitch", "Pitch", ConfigFieldType.SLIDER, + default=0.0, min=-12, max=12, step=1, group="Audio"), + ConfigField("split_pattern", "Split By", ConfigFieldType.CHOICE, + default=r"\n+", choices=list(COMMON_SPLIT_PATTERN_CHOICES), group="Generation"), + ConfigField("format", "Output Format", ConfigFieldType.CHOICE, + default="wav", choices=list(COMMON_OUTPUT_FORMAT_CHOICES), group="Generation"), + ConfigField("num_threads", "Parallel Threads", ConfigFieldType.INT, + default=1, min=1, max=32, step=1, group="Advanced"), + ConfigField("caching", "Enable Segment Cache", ConfigFieldType.BOOL, + default=False, group="Advanced"), + ] + + def get_voices(self, lang_code=None) -> list: + return [VoiceInfo(id="dummy", display_name="Dummy Tone", lang_code=None, is_custom=False)] + + def cancel(self) -> None: + self._engine.cancel() + + +register_engine("dummy", DummyBackendAdapter, display_name=DummyBackendAdapter.display_name) diff --git a/kokoro_gui/engines/kokoro.py b/kokoro_gui/engines/kokoro.py new file mode 100644 index 0000000..c43e4a5 --- /dev/null +++ b/kokoro_gui/engines/kokoro.py @@ -0,0 +1,137 @@ +"""Adapts the existing `KokoroEngine` to the `TTSEngineBackend` surface +(kokoro_gui/engines/base.py). + +Composition, not rewrite: `KokoroBackendAdapter` wraps a `KokoroEngine` +instance built and driven exactly as before - `kokoro_engine.py`'s +`AsyncLoopThread`/thread-pool internals and the GUI's callback wiring +(`on_progress`/`on_status`/`on_finish`) are untouched. This module changes no +behavior; it only describes that existing surface through the schema/ +capabilities contract so a schema-driven GUI panel and, eventually, a second +backend have something concrete to target (PLAN_qt_and_engine_abstraction.md +workstream 1). + +Reads `kokoro_engine.CUSTOM_VOICES_DIR` qualified, at call time (not via +`from kokoro_engine import CUSTOM_VOICES_DIR`), so tests can keep +monkeypatching that name on the `kokoro_engine` module - same convention +`kokoro_gui/engine/voices.py` already uses. +""" +from __future__ import annotations + +import os +from typing import Optional + +import kokoro_engine +from kokoro_gui.engine.voices import project_voice_dir +from kokoro_gui.engines.base import ( + BackendHooksMixin, ConfigField, ConfigFieldType, EngineCapabilities, VoiceInfo, + COMMON_SPLIT_PATTERN_CHOICES as SPLIT_PATTERN_CHOICES, + COMMON_OUTPUT_FORMAT_CHOICES as OUTPUT_FORMAT_CHOICES, bundle_asset_for, +) +from kokoro_gui.engines.registry import register_engine + + +class KokoroBackendAdapter(BackendHooksMixin): + id = "kokoro" + display_name = "Kokoro (local)" + capabilities = EngineCapabilities( + supports_voice_mixing=True, + supports_voice_cloning=False, + supports_multi_speaker_script=True, + is_local_model=True, + supports_jit_streaming=True, + ) + + def __init__(self, engine=None): + """`engine`, when given, is an existing `KokoroEngine` instance the + adapter wraps rather than constructing its own (used by the GUI at + startup and by tests). When omitted, the adapter builds a fresh + `KokoroEngine()` itself - used when switching the GUI's active + backend at runtime (see the Qt frontend's `switch_engine`), where + nothing already owns an engine instance to hand in.""" + self._engine = engine if engine is not None else kokoro_engine.KokoroEngine() + + @property + def engine(self): + """The wrapped `KokoroEngine` instance - the GUI re-points + `self.engine` at this on every backend switch so the many existing + `self.engine.*` call sites keep working unchanged.""" + return self._engine + + def get_config_schema(self) -> list: + """Reflects today's actual KokoroEngine config-dict fields (per + CLAUDE.md: "Config dicts, not typed objects" - this schema describes + that dict, it doesn't replace it). + + "voice" and "lang_code" deliberately leave `choices=None`: the voice + catalog (`TTSApp.VOICE_DB`/`LANGUAGES`) is still GUI-owned display + data as of this workstream, not engine data - `get_voices()` below + only covers the part of the catalog that *is* genuinely engine/ + filesystem state (custom voice files). Migrating the built-in voice + table itself behind the adapter is follow-on work, not required to + make the Generation tab's other fields (split pattern, format, + speed) schema-driven. + """ + return [ + ConfigField("lang_code", "Language", ConfigFieldType.CHOICE, + default="a", group="Generation"), + ConfigField("voice", "Voice", ConfigFieldType.CHOICE, + default="af_heart", group="Generation"), + ConfigField("speed", "Speed", ConfigFieldType.SLIDER, + default=1.0, min=0.5, max=2.0, step=0.1, group="Generation"), + ConfigField("pitch", "Pitch", ConfigFieldType.SLIDER, + default=0.0, min=-12, max=12, step=1, group="Audio"), + ConfigField("split_pattern", "Split By", ConfigFieldType.CHOICE, + default=r"\n+", choices=list(SPLIT_PATTERN_CHOICES), group="Generation"), + ConfigField("format", "Output Format", ConfigFieldType.CHOICE, + default="wav", choices=list(OUTPUT_FORMAT_CHOICES), group="Generation"), + ConfigField("num_threads", "Parallel Threads", ConfigFieldType.INT, + default=1, min=1, max=32, step=1, group="Advanced"), + ConfigField("caching", "Enable Segment Cache", ConfigFieldType.BOOL, + default=True, group="Advanced"), + ConfigField("lexicon", "Lexicon Substitutions", ConfigFieldType.TEXT, + default={}, group="Advanced"), + ] + + def get_voices(self, lang_code: Optional[str] = None) -> list: + """Custom voices: the open project's `engines/kokoro/voices/` first, + then `CUSTOM_VOICES_DIR`; a name in both shows once and resolves to + the project copy (grill TB3). The built-in named voices (af_heart, + bm_daniel, ...) aren't listed here; see the `get_config_schema` + docstring for why.""" + dirs = [] + if self.project_dir: + dirs.append(project_voice_dir(self.project_dir)) + dirs.append(kokoro_engine.CUSTOM_VOICES_DIR) + seen = [] + for directory in dirs: + if not os.path.isdir(directory): + continue + for f in sorted(os.listdir(directory)): + if f.endswith(".pt") and f[:-3] not in seen: + seen.append(f[:-3]) + return [VoiceInfo(id=name, display_name=name, lang_code=None, is_custom=True) for name in seen] + + def collect_project_assets(self, voice_names, project_dir=None) -> tuple: + """The custom `.pt` mixes among `voice_names`, as + `engines/kokoro/voices/.pt`. Built-in voices resolve to no + file and are skipped; so is a mix the user has deleted.""" + assets = [] + for name in sorted(voice_names): + asset = bundle_asset_for(name, kokoro_engine.CUSTOM_VOICES_DIR, ".pt", + "engines/kokoro/voices", project_dir) + if asset is not None: + assets.append(asset) + return assets, {} + + async def mix_voices(self, v1_name: str, v2_name: str, ratio: float, + new_name: str, op: str = "mix"): + """`SupportsVoiceMixing` extension - delegates straight to the + wrapped engine's tensor math (kokoro_gui/engine/voices.py), which + stays exactly where it is per the plan.""" + return await self._engine.mix_voices(v1_name, v2_name, ratio, new_name, op) + + def cancel(self) -> None: + self._engine.cancel() + + +register_engine("kokoro", KokoroBackendAdapter, display_name=KokoroBackendAdapter.display_name) diff --git a/kokoro_gui/engines/registry.py b/kokoro_gui/engines/registry.py new file mode 100644 index 0000000..ccc151d --- /dev/null +++ b/kokoro_gui/engines/registry.py @@ -0,0 +1,50 @@ +"""Backend registry: `register_engine`/`get_engine`/`list_engines`. + +A "factory" here is any callable that returns a `TTSEngineBackend` instance +- for the built-in `kokoro.py` adapter that's the `KokoroBackendAdapter` +class itself, called with the already-constructed `KokoroEngine` it wraps +(`get_engine("kokoro", engine=some_kokoro_engine)`), since the adapter is +composition over an existing engine instance, not a from-scratch factory. +""" +from __future__ import annotations + +from typing import Callable, Dict + +_registry: Dict[str, Callable[..., object]] = {} +_display_names: Dict[str, str] = {} + + +def register_engine(engine_id: str, factory: Callable[..., object], display_name: str = None) -> None: + """Register `factory` under `engine_id`. Re-registering the same id + overwrites the previous factory (useful for tests that register a fake + backend under a throwaway id).""" + _registry[engine_id] = factory + if display_name is not None: + _display_names[engine_id] = display_name + + +def get_engine(engine_id: str, *args, **kwargs): + """Construct and return the backend registered under `engine_id`.""" + if engine_id not in _registry: + raise KeyError( + f"No engine backend registered under {engine_id!r}. " + f"Known engines: {sorted(_registry)}" + ) + return _registry[engine_id](*args, **kwargs) + + +def list_engines() -> list: + """Return the sorted list of registered engine ids.""" + return sorted(_registry) + + +def get_display_name(engine_id: str) -> str: + """Human-readable name for `engine_id`, falling back to the id itself if + none was given at registration time.""" + return _display_names.get(engine_id, engine_id) + + +def unregister_engine(engine_id: str) -> None: + """Remove a registered engine id (mainly for test teardown).""" + _registry.pop(engine_id, None) + _display_names.pop(engine_id, None) diff --git a/kokoro_gui/qt/__init__.py b/kokoro_gui/qt/__init__.py new file mode 100644 index 0000000..6a19084 --- /dev/null +++ b/kokoro_gui/qt/__init__.py @@ -0,0 +1,7 @@ +"""PySide6 (Qt) frontend — Workstream 3a of PLAN_qt_and_engine_abstraction.md. + +This is the sole GUI frontend (`python main.py`). It talks to `KokoroEngine` / +the `kokoro_gui.engines` backend registry through the same interface the +retired Tk frontend (`gui.py`, `kokoro_gui/ui/*.py`) used to — nothing here +depends on anything Tk-specific. +""" diff --git a/kokoro_gui/qt/app.py b/kokoro_gui/qt/app.py new file mode 100644 index 0000000..849c245 --- /dev/null +++ b/kokoro_gui/qt/app.py @@ -0,0 +1,1829 @@ +"""QtTTSApp: the PySide6 shell, the sole GUI frontend since the CustomTkinter +app (`gui.py`, `kokoro_gui/ui/*.py`) was retired. + +Reshaped by Claude/PLAN_ui_shell_redesign.md into the wireframe's 2x2 grid: +Transcript (top-left) | Settings / Audio FX / Lexicon / Voices tabs +(top-right), Timeline (bottom-left) | Transport (bottom-right). Every panel +is still a `QDockWidget`; `arrange_docks_default()` builds the grid and +`kokoro_gui.qt.workspace` saves/restores named layouts (Workspace menu). +The old toolbar and the central action bar are gone - engine/device/theme +live under Options, generate/preview/cancel and the progress line live in +the Transport dock. A File menu (`kokoro_gui.qt.project`) replaced the +implicit single `document.json`. + +Projects are `.tbaw` bundles (Claude/old/PLAN_tbaw_bundle.md). The live project +is a directory under `cache/projects//` (`self.project_dir`) +that autosave writes JSON into and clips generate straight into; Save +rewrites the zip from it on a background thread behind `is_busy`, Open +extracts into it the same way with the editor read-only, and Close asks +Save / Discard / Cancel when the dir is ahead of the file. `project.py` +holds the steps; this class holds the sequencing, the lock and the dirty +flag. + +`CONFIG_FILE`/`PRESETS_DIR`/`FX_PRESETS_DIR`/`DOCUMENT_FILE` are defined +here, at module level, before the `kokoro_gui.qt.docks` import below - the +dock modules do `import kokoro_gui.qt.app as qt_app_module` and read +`qt_app_module.PRESETS_DIR` etc. qualified at call time, which makes this a +circular import; defining these names before triggering that import keeps +it safe. +""" +from __future__ import annotations + +import json +import os +import tempfile +import threading +import time + +import playback +from PySide6.QtCore import QTimer, Qt, Signal +from PySide6.QtGui import QAction, QActionGroup, QKeySequence, QShortcut +from PySide6.QtWidgets import QApplication, QFileDialog, QMainWindow, QMessageBox, QSizePolicy, QWidget + +from kokoro_engine import KokoroEngine +from kokoro_gui.daw.arrangement import compute_arrangement +from kokoro_gui.daw.auto_split import plan_auto_split_clips +from kokoro_gui.daw.undo import AssignCharacterCommand +from kokoro_gui.engine import caching +from kokoro_gui.engines import registry as engine_registry +from kokoro_gui.qt import document_state, fx_resolve, project as project_io, spec, theme +from kokoro_gui.qt import settings as qt_settings +from kokoro_gui.qt.selection import SelectionModel +from kokoro_gui.qt.signals import EngineSignalBridge, wire_engine +from kokoro_gui.qt.workspace import ADVANCED, SIMPLE, WorkspaceManager + +CONFIG_FILE = "config_qt.json" +PRESETS_DIR = "presets" +FX_PRESETS_DIR = os.path.join(PRESETS_DIR, "fx") +# The project a fresh install (or a config with no last_project) opens. +DOCUMENT_FILE = "document.json" + +from kokoro_gui.audio import post # noqa: E402 +from kokoro_gui.audio.transport import ScheduledClip, Transport # noqa: E402 +from kokoro_gui.daw.arrangement import clip_audio_duration_s # noqa: E402 +from kokoro_gui.qt.characters_dialog import CharactersDialog # noqa: E402 +from kokoro_gui.qt.docks import ( # noqa: E402 + FXDock, LexiconDock, MixingDock, SettingsDock, TimelineDock, TranscriptDock, TransportDock, + VoiceCloneDock, +) +from kokoro_gui.qt.docks.export_dialog import ExportDialog, run_export # noqa: E402 +from kokoro_gui.qt.welcome_dialog import WelcomeDialog # noqa: E402 + +APP_NAME = "KokoroGUI" +SCHEDULE_REBUILD_DEBOUNCE_MS = 100 + + +class QtTTSApp(QMainWindow): + previewFinished = Signal(bool, str) + themeChanged = Signal() + exportProgress = Signal(float, str) + exportFinished = Signal(bool, str) + # Background project I/O (Open's audio extraction, Save's zip write): + # progress as (percent, detail), completion as (callback, result, error) + # marshalled onto the GUI thread. + projectIoProgress = Signal(float, str) + _projectIoFinished = Signal(object) + + def __init__(self, parent=None): + super().__init__(parent) + self.resize(1600, 1000) + + os.makedirs(PRESETS_DIR, exist_ok=True) + os.makedirs(FX_PRESETS_DIR, exist_ok=True) + + self.settings = qt_settings.load_settings(CONFIG_FILE) + self.jit_enabled = self.settings.get("jit_enabled", False) + self.timecode_format = "%Y%m%d%H%M%S" + + # Project (section 7): the last-opened project, else the classic + # document.json next to the config, else a fresh migration. + self.project_path: str | None = None + self.project_settings: dict = {} + # The live project directory (Claude/old/PLAN_tbaw_bundle.md section 3): + # `cache/projects//`, held under an OS lock for as long + # as the project is open. Every config the segment key or the engine + # sees carries it as `project_dir`. `_project_dirty` is the + # session's "dir is ahead of the file" flag, set by autosave from a + # content digest and cleared by Save. + self.project_dir: str | None = None + self.project_id: str | None = None + self._project_lock: project_io.ProjectLock | None = None + self._project_manifest: dict = {} + self._project_dirty = False + self._io_thread: threading.Thread | None = None + self._pending_open_path: str | None = None + self._closing_after_save = False + self._closed = False + self._asset_backends: dict = {} + self.projectIoProgress.connect(self._on_project_io_progress) + self._projectIoFinished.connect(self._on_project_io_finished) + self.document = self._load_initial_document() + + self.selection = SelectionModel() + + self._save_timer = QTimer(self) + self._save_timer.setSingleShot(True) + self._save_timer.timeout.connect(self.save_settings) + + self._schedule_timer = QTimer(self) + self._schedule_timer.setSingleShot(True) + self._schedule_timer.setInterval(SCHEDULE_REBUILD_DEBOUNCE_MS) + self._schedule_timer.timeout.connect(self._rebuild_transport_schedule) + + self.welcome_dialog: WelcomeDialog | None = None + self.transcript_dock: TranscriptDock | None = None + self.settings_dock: SettingsDock | None = None + self.fx_dock: FXDock | None = None + self.lexicon_dock: LexiconDock | None = None + self.mixing_dock: MixingDock | None = None + self.voice_clone_dock: VoiceCloneDock | None = None + self.timeline_dock: TimelineDock | None = None + self.transport_dock: TransportDock | None = None + + # --- Engine / backend --- + self.engine = KokoroEngine() + self.bridge = EngineSignalBridge() + wire_engine(self.engine, self.bridge) + self.backend = engine_registry.get_engine("kokoro", engine=self.engine) + self._connect_bridge(self.bridge) + self._install_segment_key_fn() + + self.previewFinished.connect(self._on_preview_finished) + self.exportProgress.connect(self._on_export_progress) + self.exportFinished.connect(self._on_export_finished) + + # Theme before any custom-painted widget exists, so their first + # paint already reads the right palette. + theme.apply(QApplication.instance(), self.settings.get("theme", theme.DEFAULT_THEME)) + + # Transport (section 5) before the docks: the Timeline dock wires + # its playhead to transport.positionChanged at construction. + self.transport = Transport(self) + self.transport.positionChanged.connect(self._on_transport_position) + self.transport.stateChanged.connect(self._on_transport_state) + + self._build_menu_bar() + self._build_docks() + self._build_shortcuts() + + self.workspaces = WorkspaceManager(self, self.settings) + self.workspaces.restore_on_launch() + self._sync_workspace_actions() + + self._arrangement = None + self._rebuild_transport_schedule() + self._update_window_title() + + self.set_status("Initializing engine...") + init_lang_code = self.settings_dock.get_state().get("lang_code", "a") + self.engine.worker.run_coro(self.engine.init_pipeline_async(init_lang_code, device=self.settings.get("device", "auto"))) + + self.backend.on_project_opened(self.project_dir, {}) + if self._pending_open_path: + path, self._pending_open_path = self._pending_open_path, None + self.open_project(path) + + # --- project bootstrap ------------------------------------------------ + + def _load_initial_document(self): + """The window starts on an Untitled project in a fresh project dir. + The last project (or the 4.0-preview `document.json` next to the + config, which migrates to `document.tbaw`) is opened right after + the docks exist, since Open extracts on a thread with progress on + the transport bar. Characters for a first run come from the + presets directory, as before.""" + candidates = [] + last = self.settings.get("last_project") + if last and os.path.isfile(last): + candidates.append(last) + if os.path.isfile(DOCUMENT_FILE): + candidates.append(DOCUMENT_FILE) + self._pending_open_path = candidates[0] if candidates else None + self.project_path = None + self.project_settings = {} + if self._pending_open_path: + document = project_io.new_document_from(None) + else: + document = document_state.load_or_create_document(DOCUMENT_FILE, self.settings, PRESETS_DIR) + self._begin_untitled_project_dir(document) + return document + + def _begin_untitled_project_dir(self, document) -> None: + """New: a fresh dir with an empty `document.json` and the lock, so an + Untitled project has somewhere to generate into before its first + Save (Claude/old/PLAN_tbaw_bundle.md section 3).""" + project_dir, project_id = project_io.create_project_dir() + self._project_lock = project_io.ProjectLock(project_dir).acquire() + self.project_dir = project_dir + self.project_id = project_id + self._project_manifest = {} + digest = project_io.autosave_to_dir(document, self.project_settings, project_dir) + project_io.write_session(project_dir, { + "source_path": None, "zip_size": None, "zip_mtime": None, + "saved_digest": digest, "dirty": False, "asset_index": {}, + }) + self._project_dirty = False + + def _backend_for(self, engine_id: str): + """The adapter for `engine_id`: the active one when it matches, else + one built once and kept (Save collects assets for every engine the + document uses; building an adapter starts its worker thread but + loads no model), or None for an engine that isn't registered.""" + if engine_id == self.backend.id: + return self.backend + if engine_id in self._asset_backends: + return self._asset_backends[engine_id] + try: + backend = engine_registry.get_engine(engine_id) + except Exception: # noqa: BLE001 - an unregistered id is a warning, not a crash + return None + self._asset_backends[engine_id] = backend + return backend + + # --- construction ----------------------------------------------------- + + def _connect_bridge(self, bridge: EngineSignalBridge) -> None: + bridge.status.connect(self.on_engine_status) + bridge.progress.connect(self.on_engine_progress) + bridge.finished.connect(self.on_engine_finish) + + def _disconnect_bridge(self, bridge: EngineSignalBridge) -> None: + try: + bridge.status.disconnect(self.on_engine_status) + bridge.progress.disconnect(self.on_engine_progress) + bridge.finished.disconnect(self.on_engine_finish) + except Exception: + pass + + def _build_menu_bar(self) -> None: + bar = self.menuBar() + + # File + self.file_menu = bar.addMenu("&File") + self.new_action = self._action("&New", self.new_project, QKeySequence.StandardKey.New) + self.open_action = self._action("&Open...", self.open_project_dialog, QKeySequence.StandardKey.Open) + self.recent_menu = self.file_menu.addMenu("Recent") + self.welcome_action = self._action("&Welcome...", self.show_welcome) + self.save_action = self._action("&Save", self.save_project, QKeySequence.StandardKey.Save) + self.save_as_action = self._action("Save &As...", self.save_project_as_dialog, QKeySequence.StandardKey.SaveAs) + self.file_menu.insertAction(self.recent_menu.menuAction(), self.new_action) + self.file_menu.insertAction(self.recent_menu.menuAction(), self.open_action) + self.file_menu.addAction(self.welcome_action) + self.file_menu.addSeparator() + self.file_menu.addAction(self.save_action) + self.file_menu.addAction(self.save_as_action) + self.file_menu.addSeparator() + self.import_text_action = self._action("Import &Text...", self.import_text_dialog) + self.file_menu.addAction(self.import_text_action) + self.import_audio_action = QAction("Import Audio...", self) + self.import_audio_action.setEnabled(False) + self.import_audio_action.setToolTip("coming with ASR-anchored import") + self.file_menu.addAction(self.import_audio_action) + self.export_action = self._action("&Export...", self.export_dialog, "Ctrl+E") + self.file_menu.addAction(self.export_action) + self.file_menu.addSeparator() + self.quit_action = self._action("&Quit", self.close, QKeySequence.StandardKey.Quit) + self.file_menu.addAction(self.quit_action) + self._rebuild_recent_menu() + + # Edit + self.edit_menu = bar.addMenu("&Edit") + self.undo_action = self._action("Undo", self.undo, QKeySequence.StandardKey.Undo) + self.redo_action = self._action("Redo", self.redo, QKeySequence.StandardKey.Redo) + self.edit_menu.addAction(self.undo_action) + self.edit_menu.addAction(self.redo_action) + self.edit_menu.addSeparator() + self.cut_action = self._action("Cu&t", lambda: self._editor_call("cut")) + self.copy_action = self._action("&Copy", lambda: self._editor_call("copy")) + self.paste_action = self._action("&Paste", lambda: self._editor_call("paste")) + for a in (self.cut_action, self.copy_action, self.paste_action): + self.edit_menu.addAction(a) + self.edit_menu.addSeparator() + self.characters_action = self._action("&Characters...", self.open_characters_dialog) + self.edit_menu.addAction(self.characters_action) + + # Options + self.options_menu = bar.addMenu("&Options") + self.engine_menu = self.options_menu.addMenu("Engine") + self.engine_group = QActionGroup(self) + self.engine_group.setExclusive(True) + self.engine_actions: dict = {} + for engine_id in engine_registry.list_engines(): + action = QAction(engine_registry.get_display_name(engine_id), self) + action.setCheckable(True) + action.setChecked(engine_id == self.backend.id) + action.triggered.connect(lambda checked=False, eid=engine_id: self.on_engine_action(eid)) + self.engine_group.addAction(action) + self.engine_menu.addAction(action) + self.engine_actions[engine_id] = action + + self.device_menu = self.options_menu.addMenu("Device") + self.device_group = QActionGroup(self) + self.device_group.setExclusive(True) + self.device_actions: dict = {} + cuda_ok = self._cuda_available() + for device_id, label in (("auto", "Auto"), ("cpu", "CPU"), ("cuda", "CUDA")): + action = QAction(label, self) + action.setCheckable(True) + action.setChecked(self.settings.get("device", "auto") == device_id) + if device_id == "cuda" and not cuda_ok: + action.setEnabled(False) + action.setToolTip("torch reports no CUDA device") + action.triggered.connect(lambda checked=False, d=device_id: self.set_device(d)) + self.device_group.addAction(action) + self.device_menu.addAction(action) + self.device_actions[device_id] = action + + self.theme_menu = self.options_menu.addMenu("Theme") + self.theme_group = QActionGroup(self) + self.theme_group.setExclusive(True) + self.theme_actions: dict = {} + for theme_id, label in (("light", "Light"), ("dark", "Dark")): + action = QAction(label, self) + action.setCheckable(True) + action.setChecked(self.settings.get("theme", theme.DEFAULT_THEME) == theme_id) + action.triggered.connect(lambda checked=False, t=theme_id: self.set_theme(t)) + self.theme_group.addAction(action) + self.theme_menu.addAction(action) + self.theme_actions[theme_id] = action + + self.options_menu.addSeparator() + self.copy_carries_action = QAction("Copy carries character/FX", self) + self.copy_carries_action.setCheckable(True) + self.copy_carries_action.setChecked(bool(self.settings.get("character_fx_copy", True))) + self.copy_carries_action.toggled.connect(lambda v: self._set_setting("character_fx_copy", v)) + self.options_menu.addAction(self.copy_carries_action) + + self.paste_splits_action = QAction("Paste splits character/FX", self) + self.paste_splits_action.setCheckable(True) + self.paste_splits_action.setChecked(bool(self.settings.get("character_fx_paste_splits", True))) + self.paste_splits_action.toggled.connect(lambda v: self._set_setting("character_fx_paste_splits", v)) + self.options_menu.addAction(self.paste_splits_action) + + self.jit_action = QAction("JIT streaming (no-clips fallback only)", self) + self.jit_action.setCheckable(True) + self.jit_action.setChecked(bool(self.jit_enabled)) + self.jit_action.toggled.connect(self._on_jit_toggled) + self.options_menu.addAction(self.jit_action) + self._sync_jit_action_enabled() + + # Workspace + self.workspace_menu = bar.addMenu("&Workspace") + self.workspace_group = QActionGroup(self) + self.workspace_group.setExclusive(True) + self.workspace_actions: dict = {} + for name in (ADVANCED, SIMPLE): + action = QAction(name, self) + action.setCheckable(True) + action.triggered.connect(lambda checked=False, n=name: self.activate_workspace(n)) + self.workspace_group.addAction(action) + self.workspace_menu.addAction(action) + self.workspace_actions[name] = action + self.workspace_menu.addSeparator() + self.reset_layout_action = self._action("Reset layout", self.reset_workspace) + self.workspace_menu.addAction(self.reset_layout_action) + + def _action(self, text: str, slot, shortcut=None) -> QAction: + action = QAction(text, self) + if shortcut is not None: + action.setShortcut(QKeySequence(shortcut) if isinstance(shortcut, str) else shortcut) + action.triggered.connect(slot) + return action + + @staticmethod + def _cuda_available() -> bool: + try: + import torch + + return bool(torch.cuda.is_available()) + except Exception: + return False + + def _build_docks(self) -> None: + self.transcript_dock = TranscriptDock(self) + self.settings_dock = SettingsDock(self) + self.fx_dock = FXDock(self) + self.lexicon_dock = LexiconDock(self) + self.timeline_dock = TimelineDock(self) + self.transport_dock = TransportDock(self) + + self.timeline_dock.batchGenerationProgress.connect(self.on_batch_generation_progress) + self.timeline_dock.batchGenerationFinished.connect(self.on_batch_generation_finished) + self.timeline_dock.timeline_view.seekRequested.connect(self.transport.seek) + + self.transport_dock.playRequested.connect(self.transport.play) + self.transport_dock.pauseRequested.connect(self.transport.pause) + self.transport_dock.stopRequested.connect(self.transport.stop) + self.transport_dock.loopToggled.connect(self._on_loop_toggled) + + # A QMainWindow needs a central widget; the docks fill everything. + # Hidden with an Ignored size policy, NOT setFixedSize(0, 0): a fixed + # 0x0 central widget caps the maximum height of the row it sits in, + # so the docks sharing that row (the timeline) could never be made + # taller than their minimum by dragging the separator above them. + central = QWidget() + central.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) + central.hide() + self.setCentralWidget(central) + self.setDockNestingEnabled(True) + + self.arrange_docks_default() + + def arrange_docks_default(self) -> None: + """The drawing's 2x2 grid. Also what Workspace > Reset rebuilds. + + Top row (transcript | settings tabs) in the Top dock area, bottom row + (timeline | transport) in the Left area next to the hidden central + widget. Two areas, one separator between the rows, both rows + resizable - the arrangement the maintainer settled on by dragging.""" + for dock in self._all_docks(): + self.removeDockWidget(dock) + + top = Qt.DockWidgetArea.TopDockWidgetArea + bottom = Qt.DockWidgetArea.LeftDockWidgetArea + self.addDockWidget(top, self.transcript_dock) + self.addDockWidget(top, self.settings_dock) + for dock in (self.fx_dock, self.lexicon_dock): + self.addDockWidget(top, dock) + self.tabifyDockWidget(self.settings_dock, dock) + self._sync_mixing_dock() + self._sync_voice_clone_dock() + for voices_dock in (self.mixing_dock, self.voice_clone_dock): + if voices_dock is not None: + self._place_voices_dock(voices_dock) + self.addDockWidget(bottom, self.timeline_dock) + self.addDockWidget(bottom, self.transport_dock) + self.splitDockWidget(self.timeline_dock, self.transport_dock, Qt.Orientation.Horizontal) + + for dock in self._all_docks(): + dock.setFloating(False) + dock.setVisible(True) + self.settings_dock.raise_() + self.apply_default_proportions() + + def apply_default_proportions(self) -> None: + """Left column ~65% of the width, top row ~65% of the height. + `resizeDocks` only sticks once the dock layout is active, so this + runs again from the first `showEvent`.""" + width = max(self.width(), 800) + height = max(self.height(), 600) + left_w, right_w = int(width * 0.65), int(width * 0.35) + top_h, bottom_h = int(height * 0.65), int(height * 0.35) + self.resizeDocks([self.transcript_dock, self.settings_dock], [left_w, right_w], Qt.Orientation.Horizontal) + self.resizeDocks([self.timeline_dock, self.transport_dock], [left_w, right_w], Qt.Orientation.Horizontal) + self.resizeDocks([self.transcript_dock, self.timeline_dock], [top_h, bottom_h], Qt.Orientation.Vertical) + + def apply_simple_proportions(self) -> None: + """Workspace > Simple: the timeline is hidden, so the bottom row only + needs the transport's three rows.""" + height = max(self.height(), 600) + bottom_h = 140 + self.resizeDocks([self.transcript_dock, self.transport_dock], [height - bottom_h, bottom_h], + Qt.Orientation.Vertical) + + def showEvent(self, event) -> None: # noqa: N802 (Qt override) + super().showEvent(event) + if not getattr(self, "_shown_once", False): + self._shown_once = True + if hasattr(self, "workspaces") and self.workspaces.saved(self.workspaces.active) is None: + QTimer.singleShot(0, lambda: self.workspaces.apply_default(self.workspaces.active)) + + def _all_docks(self) -> list: + docks = [self.transcript_dock, self.settings_dock, self.fx_dock, self.lexicon_dock, + self.mixing_dock, self.voice_clone_dock, self.timeline_dock, self.transport_dock] + return [d for d in docks if d is not None] + + def _build_shortcuts(self) -> None: + # UI12: plain Space toggles playback anywhere the focus widget + # doesn't claim it (the editor and line edits accept it as text via + # ShortcutOverride); Ctrl+Space always toggles. + self.space_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Space), self) + self.space_shortcut.setContext(Qt.ShortcutContext.WindowShortcut) + self.space_shortcut.activated.connect(self.transport.toggle) + self.ctrl_space_shortcut = QShortcut(QKeySequence("Ctrl+Space"), self) + self.ctrl_space_shortcut.setContext(Qt.ShortcutContext.ApplicationShortcut) + self.ctrl_space_shortcut.activated.connect(self.transport.toggle) + + # --- status helpers --------------------------------------------------- + + def set_status(self, message: str, kind: str = "info") -> None: + if self.transport_dock is not None: + self.transport_dock.set_status(message, kind) + + def is_busy(self) -> bool: + return self.transport_dock is not None and self.transport_dock.is_busy() + + @property + def editor(self): + return self.transcript_dock.editor if self.transcript_dock is not None else None + + def _editor_call(self, method: str) -> None: + editor = self.editor + if editor is not None: + getattr(editor, method)() + + def raise_fx_tab(self) -> None: + if self.fx_dock is not None: + self.fx_dock.show() + self.fx_dock.raise_() + + # --- voice listing -- + + def get_all_voices(self, lang_code: str | None = None) -> list: + """`spec.VOICE_DB` is Kokoro's built-in named-voice table; the + backend-provided half comes from `self.backend.get_voices(...)`.""" + if lang_code is None: + lang_code = self.settings.get("lang_code", "a") + standard = spec.VOICE_DB.get(lang_code, []) + custom = [v.id for v in self.backend.get_voices(lang_code)] + return sorted(set(standard + custom)) + + # --- settings persistence - + + def schedule_save(self) -> None: + self._save_timer.start(1000) + self._update_window_title(pending=True) + + def refresh_timeline(self) -> None: + """App-owned cross-dock coordination point: re-render the timeline + and (debounced) rebuild the transport's schedule.""" + if self.timeline_dock is not None: + self.timeline_dock.refresh() + self._schedule_timer.start() + if self.transcript_dock is not None: + self.transcript_dock.sync_header() + + def save_settings(self) -> None: + self._save_timer.stop() + + if self.settings_dock is not None: + gen_state = self.settings_dock.get_state() + for key in ("lang_code", "voice", "speed", "volume", "pitch", "num_threads", "split_pattern", + "caching", "normalize", "format"): + if key in gen_state: + self.settings[key] = gen_state[key] + self.settings["trim"] = gen_state.get("trim_silence", self.settings.get("trim", False)) + self.settings["apply_fx"] = self.settings_dock.apply_fx_enabled() + self.settings["jit_enabled"] = self.jit_enabled + self.settings["engine_id"] = self.backend.id + self.settings.update(self.fx_dock.project_fx_state()) + if self.voice_clone_dock is not None: + self.settings.update(self.voice_clone_dock.get_state()) + if hasattr(self, "workspaces"): + self.workspaces.capture() + + qt_settings.save_settings(CONFIG_FILE, self.settings) + self._autosave_project_dir() + self._update_window_title(pending=False) + + def _autosave_project_dir(self) -> None: + """Writes `document.json`/`project.json` into the project dir and + sets the session's `dirty` iff the digest differs from what the + last Save or Open recorded. A content comparison, not an mtime: + `_switch_document`'s trailing `schedule_save` would otherwise dirty + every project a second after Open. Never touches the `.tbaw`.""" + if not self.project_dir: + return + try: + digest = project_io.autosave_to_dir(self.document, self.project_settings, self.project_dir) + except Exception as e: # noqa: BLE001 - autosave must never crash the UI + self.set_status(f"Autosave failed: {e}", "error") + return + session = project_io.read_session(self.project_dir) or {} + dirty = digest != session.get("saved_digest") + if bool(session.get("dirty")) != dirty: + session["dirty"] = dirty + try: + project_io.write_session(self.project_dir, session) + except OSError as e: + self.set_status(f"Autosave failed: {e}", "error") + self._project_dirty = dirty + + def is_project_dirty(self) -> bool: + """True when the project dir is ahead of the `.tbaw` on disk (or an + Untitled project has edits). Flushes a pending autosave first so a + keystroke a moment ago counts.""" + if self._save_timer.isActive(): + self.save_settings() + return self._project_dirty + + def _set_setting(self, key: str, value) -> None: + self.settings[key] = value + self.schedule_save() + + def _update_window_title(self, pending: bool = False) -> None: + name = project_io.project_title(self.project_path) + star = "*" if (pending or self._project_dirty) else "" + self.setWindowTitle(f"{name}{star} - {APP_NAME}") + + # --- config assembly ---------------- + + def _export_values(self) -> dict: + from kokoro_gui.qt.docks.export_dialog import export_defaults + + return export_defaults(self) + + def _assemble_config(self) -> dict: + gen_state = self.settings_dock.get_state() + export = self._export_values() + config = { + "engine_id": self.backend.id, + "lang_code": gen_state["lang_code"], + "voice": gen_state["voice"], + "speed": gen_state["speed"], + "split_pattern": gen_state["split_pattern"], + # Sanitize the free-text filename field the same way voice/preset + # names are sanitized elsewhere - it flows unvalidated into an + # os.path.join sink in caching.py otherwise. + "filename": os.path.basename(export["filename"]), + "format": export["format"], + "out_dir": export["out_dir"], + "separate": export["keep_clip_files"], + "combine": True, + "export_subtitles": export["srt"], + "caching": gen_state["caching"], + "time_id": time.strftime(self.timecode_format), + "num_threads": gen_state["num_threads"], + "volume": gen_state["volume"], + "pitch": gen_state["pitch"], + "normalize": gen_state["normalize"], + "trim_silence": gen_state["trim_silence"], + "lexicon": self.settings.get("lexicon", {}), + } + if self.settings_dock.apply_fx_enabled(): + config.update(self.fx_dock.project_fx_state()) + return config + + def _assemble_generation_config(self, clip) -> dict: + """Exactly the inputs that decide what a clip's audio *is*: the + segment key hashes these and nothing else, and `_assemble_clip_config` + is built on top. App defaults from the Settings tab, the backend's + "Model" schema group (Audio8's sampling knobs), then the clip's + `effective_config_for_clip` (character preset, then overrides) on + top, then `project_dir` and the clip's take. The take is read off + `clip.overrides` directly, not through the `ALLOWED_PRESET_KEYS` + whitelist, which is for untrusted preset files and shouldn't widen + for a runtime counter. One method decides what both the dirty check + and a Generate hash, so they can't drift.""" + gen_state = self.settings_dock.get_state() + config = { + "engine_id": self.backend.id, + "lang_code": gen_state["lang_code"], + "voice": gen_state["voice"], + "speed": gen_state["speed"], + "pitch": gen_state["pitch"], + "split_pattern": gen_state["split_pattern"], + } + for field in self.backend.get_config_schema(): + if field.group == "Model" and field.key in gen_state: + config[field.key] = gen_state[field.key] + clip_config = dict(self.document.effective_config_for_clip(clip)) + for key in ("voice", "speed", "pitch", "split_pattern", "lang_code"): + if key in clip_config: + config[key] = clip_config[key] + config["project_dir"] = self.project_dir + config["take"] = int(clip.overrides.get("take", 0) or 0) + return config + + def _assemble_clip_config(self, clip) -> dict: + """The config dict for a per-clip Generate action and, through + `post_config_for_clip`, for read-time post-processing: + `_assemble_generation_config` plus everything that doesn't change + the audio (output location and naming, threads, caching, the post + keys) with `clip`'s character/override settings merged on top, so + the clip's own values win. Defaults must come first: + `process_chunk_task` reads `config['voice']`/`config['split_pattern']` + by direct indexing, so a clip with no character must still end up + with usable defaults. FX come from `fx_resolve.resolve_fx`, the same + resolver the Audio FX tab renders.""" + gen_state = self.settings_dock.get_state() + export = self._export_values() + config = { + "format": export["format"], + "out_dir": export["out_dir"], + "caching": gen_state["caching"], + "time_id": time.strftime(self.timecode_format), + "num_threads": gen_state["num_threads"], + "volume": gen_state["volume"], + "normalize": gen_state["normalize"], + "trim_silence": gen_state["trim_silence"], + "filename": os.path.basename(export["filename"]), + } + for key in ("cache_reference_codes",): + if key in gen_state: + config[key] = gen_state[key] + + clip_config = dict(self.document.effective_config_for_clip(clip)) + # ALLOWED_PRESET_KEYS whitelists "trim", but process_audio reads + # "trim_silence" - same inline rename every other caller does. + if "trim" in clip_config: + clip_config["trim_silence"] = clip_config.pop("trim") + config.update(clip_config) + config.update(self._assemble_generation_config(clip)) + if self.project_dir: + # Clips generate straight into the project dir, once, named by + # their segment key (Claude/old/PLAN_tbaw_bundle.md section 3). The + # bundle's audio format decides the extension of new segments, + # over a preset's `format` (that one is for the export path). + config["out_dir"] = os.path.join(self.project_dir, *project_io.AUDIO_GENERATED.split("/")) + config["segment_naming"] = "cache_key" + config["format"] = project_io.bundle_options(self.project_settings)["audio_format"] + + # effective_config_for_clip only ever carries the FX preset's *name*; + # the resolver turns project values + character preset + clip preset + # + clip.fx_override into the actual FX keys and the ANDed apply_fx. + resolution = fx_resolve.resolve_fx(self, clip=clip) + config.update(resolution.values) + config["apply_fx"] = resolution.apply_fx + return config + + def _install_segment_key_fn(self) -> None: + """Sets `Document.segment_key_fn` to a closure over the active + backend and project dir (Claude/old/PLAN_tbaw_bundle.md section 2.3): + `key_fn(text, clip, engine_version=None) -> caching.segment_key` + over `_assemble_generation_config(clip)`. Memoized on the text, the + config and the version; a hit re-checks the voice file's mtime and + the backend's `cache_key_extra` (Audio8's transcript, itself cached + by mtime), which is the only way a key changes without its inputs + changing (a re-saved mix, a re-recorded reference). So a rehighlight + of a book costs about two stats per clip and no hashing or reads.""" + backend = self.backend + memo: dict = {} + + def key_fn(text, clip, engine_version=None): + config = self._assemble_generation_config(clip) + memo_key = (text, json.dumps(config, sort_keys=True, default=str), engine_version) + name, _fp = caching.normalize_voice(config.get("voice"), backend, config.get("project_dir")) + voice_file = backend.resolve_voice_file(name, config.get("project_dir")) if name else None + try: + stamp = os.path.getmtime(voice_file) if voice_file else None + except OSError: + stamp = None + extra = backend.cache_key_extra(config) + hit = memo.get(memo_key) + if hit is not None and hit[0] == stamp and hit[1] == extra: + return hit[2] + key = caching.segment_key(text, config, backend, engine_version) + memo[memo_key] = (stamp, extra, key) + return key + + self.document.segment_key_fn = key_fn + + # --- read-time post-processing (kokoro_gui/audio/post.py) --------------- + + def post_config_for_clip(self, clip) -> dict: + """The `POST_KEYS` subset of the clip's resolved config: what the + transport, the exporter and the timeline waveform apply on top of + the raw segment files. Changing any of it never dirties the clip.""" + return post.extract_post_config(self._assemble_clip_config(clip)) + + def clip_duration_s(self, clip): + """`compute_arrangement`'s `clip_duration`: the clip's rendered + length (trim and pitch change it), or the raw `Segment.duration` + for a file that can't be read, or None with no audio at all. Falls + back to the raw durations while the docks are still being built.""" + segments = [s for s in clip.segments if s.audio_path] + if not segments: + return None + if self.settings_dock is None or self.fx_dock is None: + return clip_audio_duration_s(clip) + post_config = self.post_config_for_clip(clip) + rate = self.project_sample_rate() + total = 0.0 + for segment in segments: + try: + total += post.rendered_duration_s(segment.audio_path, post_config, rate) + except Exception: + total += segment.duration or 0.0 + return total + + def rendered_clip_samples(self, clip): + """`(samples, rate)` for the clip's segments concatenated and + post-processed, or None. The timeline draws its waveform from this + so it shows what the transport plays.""" + segments = sorted((s for s in clip.segments if s.audio_path), key=lambda s: s.order_index) + if not segments: + return None + post_config = self.post_config_for_clip(clip) + rate = self.project_sample_rate() + parts = [] + for segment in segments: + try: + parts.append(post.render(segment.audio_path, post_config, rate)) + except Exception: + continue + if not parts: + return None + import numpy as np + + return np.concatenate(parts), rate + + def build_arrangement(self): + """Every `compute_arrangement` call for the live document goes + through here so they all measure clips the same way.""" + return compute_arrangement(self.document, engine_id=self.backend.id, clip_duration=self.clip_duration_s) + + # --- Options: engine / device / theme --------------------------------- + + def on_engine_action(self, engine_id: str) -> None: + if engine_id == self.backend.id: + return + self.switch_engine(engine_id) + + def switch_engine(self, engine_id: str) -> None: + if self.is_busy(): + QMessageBox.warning(self, "Busy", "Cancel the current job before switching engines.") + self._sync_engine_actions() + return + + old_engine = self.engine + self._disconnect_bridge(self.bridge) + + new_backend = engine_registry.get_engine(engine_id) + new_engine = new_backend.engine + new_bridge = EngineSignalBridge() + wire_engine(new_engine, new_bridge) + self._connect_bridge(new_bridge) + + self.engine = new_engine + self.backend = new_backend + self.bridge = new_bridge + self._install_segment_key_fn() + + self.backend.on_project_opened(self.project_dir, self._engine_meta(self.backend.id)) + self.settings_dock.rebuild_schema_form() + self._sync_mixing_dock() + self._sync_voice_clone_dock() + self._sync_engine_actions() + self._sync_jit_action_enabled() + + try: + old_engine.worker.stop() + except Exception: + pass + + self.set_status(f"Switched engine to {new_backend.display_name}. Initializing...") + new_lang_code = self.settings_dock.get_state().get("lang_code", "a") + self.settings["lang_code"] = new_lang_code + self.engine.worker.run_coro(self.engine.init_pipeline_async(new_lang_code, device=self.settings.get("device", "auto"))) + self.schedule_save() + + def _sync_engine_actions(self) -> None: + for engine_id, action in self.engine_actions.items(): + action.setChecked(engine_id == self.backend.id) + + def _sync_jit_action_enabled(self) -> None: + supported = self.backend.capabilities.supports_jit_streaming + self.jit_action.setEnabled(supported) + self.jit_action.setToolTip("" if supported else f"{self.backend.display_name} doesn't support streaming - runs as Standard.") + + def _on_jit_toggled(self, checked: bool) -> None: + self.jit_enabled = checked + self.settings["jit_enabled"] = checked + self.schedule_save() + + def set_device(self, device: str) -> None: + self.settings["device"] = device + for device_id, action in self.device_actions.items(): + action.setChecked(device_id == device) + self.schedule_save() + if self.is_busy(): + return + lang_code = self.settings_dock.get_state().get("lang_code", "a") + self.set_status(f"Re-initializing engine on {device}...") + self.engine.worker.run_coro(self.engine.init_pipeline_async(lang_code, device=device)) + + def set_theme(self, name: str) -> None: + self.settings["theme"] = name + theme.apply(QApplication.instance(), name) + for theme_id, action in self.theme_actions.items(): + action.setChecked(theme_id == name) + self.themeChanged.emit() + self.schedule_save() + + def _sync_mixing_dock(self) -> None: + wants = self.backend.capabilities.supports_voice_mixing + if wants and self.mixing_dock is None: + self.mixing_dock = MixingDock(self) + self._place_voices_dock(self.mixing_dock) + elif not wants and self.mixing_dock is not None: + self.removeDockWidget(self.mixing_dock) + self.mixing_dock.deleteLater() + self.mixing_dock = None + elif wants and self.mixing_dock is not None and self.mixing_dock.parent() is None: + self._place_voices_dock(self.mixing_dock) + + def _sync_voice_clone_dock(self) -> None: + wants = self.backend.capabilities.supports_voice_cloning + if wants and self.voice_clone_dock is None: + self.voice_clone_dock = VoiceCloneDock(self) + self._place_voices_dock(self.voice_clone_dock) + elif not wants and self.voice_clone_dock is not None: + self.removeDockWidget(self.voice_clone_dock) + self.voice_clone_dock.deleteLater() + self.voice_clone_dock = None + elif wants and self.voice_clone_dock is not None and self.voice_clone_dock.parent() is None: + self._place_voices_dock(self.voice_clone_dock) + + def _place_voices_dock(self, dock) -> None: + """Both capability-gated voice docks share the "Voices" tab title and + objectName, so the tab strip doesn't jump when the engine changes + and a saved layout places either one in the same slot.""" + dock.setWindowTitle("Voices") + dock.setObjectName("dock_voices") + self.addDockWidget(self.dockWidgetArea(self.settings_dock), dock) + self.tabifyDockWidget(self.lexicon_dock, dock) + if self.settings_dock is not None: + self.settings_dock.raise_() + + # --- Workspace ---------------------------------------------------------- + + def activate_workspace(self, name: str) -> None: + self.workspaces.activate(name) + self._sync_workspace_actions() + self.schedule_save() + + def reset_workspace(self) -> None: + self.workspaces.reset() + self._sync_workspace_actions() + self.schedule_save() + + def _sync_workspace_actions(self) -> None: + for name, action in self.workspace_actions.items(): + action.setChecked(name == self.workspaces.active) + + # --- File menu ---------------------------------------------------------- + + def _rebuild_recent_menu(self) -> None: + self.recent_menu.clear() + recent = [p for p in self.settings.get("recent_projects", []) if isinstance(p, str)] + if not recent: + empty = self.recent_menu.addAction("(empty)") + empty.setEnabled(False) + return + for path in recent: + action = self.recent_menu.addAction(project_io.project_title(path)) + action.setToolTip(path) + action.triggered.connect(lambda checked=False, p=path: self.open_project(p)) + + def show_welcome(self) -> WelcomeDialog: + """Window-modal via `open()`, not `exec()`, so engine init keeps + reporting underneath and tests can drive it.""" + if self.welcome_dialog is None: + self.welcome_dialog = WelcomeDialog(self) + else: + self.welcome_dialog.reload() + if self.welcome_dialog.isVisible(): + self.welcome_dialog.raise_() + else: + self.welcome_dialog.open() + return self.welcome_dialog + + def show_welcome_if_enabled(self) -> WelcomeDialog | None: + """The launch-time trigger; `main.py` is its only caller, so the + test fixture and the screenshot script never get a dialog.""" + if not self.settings.get("show_welcome", True): + return None + return self.show_welcome() + + def _switch_document(self, document, path: str | None, project_settings: dict | None = None) -> None: + self.transport.stop() + self.document = document + self.project_path = os.path.abspath(path) if path else None + self.project_settings = dict(project_settings or {}) + self._install_segment_key_fn() + self.backend.on_project_opened(self.project_dir, self._engine_meta(self.backend.id)) + self.selection.clear() + self.selection.set_playing_clip(None) + if self.project_path: + project_io.remember_recent(self.settings, self.project_path) + else: + self.settings["last_project"] = None + self._rebuild_recent_menu() + if self.editor is not None: + self.editor.rebind_document() + if self.transcript_dock is not None: + self.transcript_dock.refresh_character_choices() + if self.settings_dock is not None: + self.settings_dock.rebuild_schema_form() + if self.fx_dock is not None: + self.fx_dock.refresh_for_selection() + self.refresh_timeline() + self._update_window_title() + self.schedule_save() + + def _engine_meta(self, engine_id: str) -> dict: + engines = self._project_manifest.get("engines") if isinstance(self._project_manifest, dict) else None + block = (engines or {}).get(engine_id) if isinstance(engines, dict) else None + meta = block.get("meta") if isinstance(block, dict) else None + return dict(meta) if isinstance(meta, dict) else {} + + # -- closing the current project ---------------------------------------- + + def _ask_close_choice(self) -> str: + """Save / Discard / Cancel for a dirty project (grill TB12). A + method so tests can replace it.""" + box = QMessageBox(self) + box.setWindowTitle("Unsaved changes") + box.setText(f"{project_io.project_title(self.project_path)} has unsaved changes.") + save_btn = box.addButton("Save", QMessageBox.ButtonRole.AcceptRole) + discard_btn = box.addButton("Discard", QMessageBox.ButtonRole.DestructiveRole) + box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + box.setDefaultButton(save_btn) + box.exec() + clicked = box.clickedButton() + if clicked is save_btn: + return "save" + if clicked is discard_btn: + return "discard" + return "cancel" + + def _close_current_project(self, then) -> None: + """Runs `then()` once the open project is put away: a clean project + is GC'd and released; a dirty one asks Save / Discard / Cancel, and + Save continues after the background Save succeeds.""" + if not self.project_dir: + then() + return + if not self.is_project_dirty(): + self._teardown_project(discard=False) + then() + return + choice = self._ask_close_choice() + if choice == "cancel": + return + if choice == "discard": + self._teardown_project(discard=True) + then() + return + path = self.project_path + if not path: + path = self._save_as_path_dialog() + if not path: + return + self.project_path = path + + def _after_save(): + self._teardown_project(discard=False) + then() + + self._save_bundle(self.project_path, then=_after_save) + + def _teardown_project(self, discard: bool) -> None: + """Releases the lock; on Discard deletes the dir (the zip has the + last saved state), otherwise GCs orphaned segments (TB11: only at + close, when no undo history can point at them any more).""" + if self._project_lock is not None: + if not discard: + try: + project_io.gc_project_dir(self.project_dir, self.document) + except OSError: + pass + self._project_lock.release() + self._project_lock = None + if discard and self.project_dir: + project_io.delete_project_dir(self.project_dir) + self.project_dir = None + self.project_id = None + self._project_manifest = {} + self._project_dirty = False + + def _evict_other_project_dirs(self) -> None: + """TB13: only the open project's dir stays; every other clean, + unlocked dir under `cache/projects/` goes.""" + try: + project_io.evict_project_dirs(self.project_dir) + except OSError: + pass + + # -- new ------------------------------------------------------------------ + + def new_project(self) -> None: + previous = self.document + + def _start(): + document = project_io.new_document_from(previous) + self.project_settings = {} + self._begin_untitled_project_dir(document) + self._switch_document(document, None) + self._evict_other_project_dirs() + self.set_status("New project (characters inherited from the previous one). Save As to name it.") + + self._close_current_project(_start) + + # -- open ----------------------------------------------------------------- + + def open_project(self, path: str) -> None: + if self.is_busy(): + QMessageBox.warning(self, "Busy", "Finish or cancel the current job before opening a project.") + return + if not os.path.isfile(path): + QMessageBox.warning(self, "Open failed", f"Couldn't read {path}.") + project_io.forget_recent(self.settings, path) + self._rebuild_recent_menu() + return + if project_io.format_for_path(path) != "tbaw": + self._open_json_project(path) + return + try: + info = project_io.inspect_bundle(path) + except project_io.ProjectError as e: + QMessageBox.warning(self, "Open failed", str(e)) + return + if self.project_path and os.path.abspath(path) == self.project_path and self.project_id == info.project_id: + return # already open: the welcome dialog's Resume + self._close_current_project(lambda: self._open_bundle(info)) + + def _ask_recover_choice(self, session: dict, info: project_io.BundleInfo, project_dir: str) -> str: + """Recover prompt (grill TB15): "keep" the unsaved session, "take" + the file (wipe and extract fresh) or "cancel". A method so tests can + replace it.""" + stamp = "" + try: + stamp = time.strftime("%Y-%m-%d %H:%M", time.localtime( + os.path.getmtime(os.path.join(project_dir, project_io.DOCUMENT)))) + except OSError: + pass + lines = [f"Recover unsaved changes{' from ' + stamp if stamp else ''}?"] + theirs = session.get("source_path") + if theirs and os.path.abspath(theirs) != os.path.abspath(info.path): + lines.append(f"They were made on {theirs}.") + changed = not project_io.session_matches_file(session, info) + if changed: + lines.append("The file was changed outside KokoroGUI since.") + box = QMessageBox(self) + box.setWindowTitle("Recover project") + box.setText("\n".join(lines)) + keep_btn = box.addButton("Keep session", QMessageBox.ButtonRole.AcceptRole) + take_btn = box.addButton("Take file" if changed else "Discard session", QMessageBox.ButtonRole.DestructiveRole) + box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + box.setDefaultButton(keep_btn) + box.exec() + clicked = box.clickedButton() + if clicked is keep_btn: + return "keep" + if clicked is take_btn: + return "take" + return "cancel" + + def _open_bundle(self, info: project_io.BundleInfo) -> None: + """Steps 1-5 of Open (Claude/old/PLAN_tbaw_bundle.md section 6): the + project dir and its lock, the recover prompt, the free-space check, + the small entries on this thread, the audio on a background thread + behind `is_busy` with the editor read-only.""" + project_dir = project_io.choose_project_dir(info.project_id, info.path) + try: + lock = project_io.ProjectLock(project_dir).acquire() + except project_io.ProjectLockedError as e: + QMessageBox.warning(self, "Already open", str(e)) + self._start_untitled_after_failed_open() + return + try: + project_io.sweep_orphan_dirs() + except OSError: + pass + + session = project_io.read_session(project_dir) + recovered = False + extract = True + if session and session.get("dirty") and os.path.isfile(os.path.join(project_dir, project_io.DOCUMENT)): + choice = self._ask_recover_choice(session, info, project_dir) + if choice == "cancel": + lock.release() + self._start_untitled_after_failed_open() + return + if choice == "keep": + recovered = True + extract = False + else: + project_io.wipe_project_dir(project_dir) + elif session and project_io.session_matches_file(session, info) \ + and os.path.isfile(os.path.join(project_dir, project_io.DOCUMENT)): + extract = False # a clean extraction of this very file: Resume is fast + else: + project_io.wipe_project_dir(project_dir) + + if not extract: + self._finish_open(info, project_dir, lock, recovered) + return + + try: + project_io.check_free_space(project_io.projects_root(), info.audio_bytes, "open the project") + project_io.extract_small(info, project_dir) + except (project_io.ProjectError, OSError) as e: + lock.release() + QMessageBox.warning(self, "Open failed", str(e)) + self._start_untitled_after_failed_open() + return + + if info.audio_bytes == 0: + self._finish_open(info, project_dir, lock, recovered) + return + + self._begin_project_io(f"Opening {project_io.project_title(info.path)}...", read_only=True) + + def _work(): + project_io.extract_audio(info, project_dir, progress=self._io_progress("Extracting audio")) + + def _done(_result, error): + self._end_project_io(read_only=True) + if error is not None: + lock.release() + QMessageBox.warning(self, "Open failed", str(error)) + self._start_untitled_after_failed_open() + return + self._finish_open(info, project_dir, lock, recovered) + + self._run_project_io(_work, _done) + + def _start_untitled_after_failed_open(self) -> None: + """The previous project was already put away when an Open fails + partway; the window can't sit on a document with no dir.""" + if self.project_dir: + return + document = project_io.new_document_from(self.document) + self.project_settings = {} + self._begin_untitled_project_dir(document) + self._switch_document(document, None) + + def _finish_open(self, info, project_dir: str, lock, recovered: bool) -> None: + """Steps 6 and 7: the document, the session record, the TB9 status + line, and the backend's `on_project_opened`.""" + try: + loaded = project_io.finish_open( + info, project_dir, {self.backend.id: self.backend.engine_version()}, recovered=recovered, + ) + except (OSError, ValueError, KeyError) as e: + lock.release() + QMessageBox.warning(self, "Open failed", f"Couldn't read the project: {e}") + self._start_untitled_after_failed_open() + return + self._project_lock = lock + self.project_dir = project_dir + self.project_id = info.project_id + self._project_manifest = dict(info.manifest) + self._project_dirty = bool(recovered) + self._switch_document(loaded.document, info.path, loaded.project_settings) + self._evict_other_project_dirs() + for notice in loaded.notices: + self.set_status(notice, "warning") + if not loaded.notices: + self.set_status(f"Opened {project_io.project_title(info.path)}.") + + def _open_json_project(self, path: str) -> None: + """TB6: a 4.0-preview `.json` project opens, gets a project id and a dir, + has its segments rekeyed and copied in (`migrate_segments`), and is + saved as `.tbaw` next to the `.json`, which then becomes the + recent entry. A `.tbaw` already there from an earlier migration is + opened instead. An unwritable directory falls through to Save As.""" + target = project_io.bundle_path_for(path) + if os.path.isfile(target): + self.open_project(target) + return + loaded = project_io.load_json_project(path) + if loaded is None: + QMessageBox.warning(self, "Open failed", f"Couldn't read {path}.") + project_io.forget_recent(self.settings, path) + self._rebuild_recent_menu() + return + + def _migrate(): + self.project_settings = dict(loaded.project_settings) + self._begin_untitled_project_dir(loaded.document) + self.document = loaded.document + self._install_segment_key_fn() + audio_format = project_io.bundle_options(self.project_settings)["audio_format"] + counts = project_io.migrate_segments(loaded.document, self.project_dir, self._assemble_generation_config, + self.document.segment_key_fn, audio_format) + self._switch_document(loaded.document, None, self.project_settings) + project_io.forget_recent(self.settings, path) + self._rebuild_recent_menu() + if counts["adopted"] or counts["dropped"]: + self.set_status(f"Migrated {os.path.basename(path)}: {counts['adopted']} segment(s) kept, " + f"{counts['dropped']} will regenerate.", "info") + save_to = target + if not os.access(os.path.dirname(os.path.abspath(target)) or ".", os.W_OK): + save_to = self._save_as_path_dialog() + if not save_to: + return + self.project_path = os.path.abspath(save_to) + project_io.remember_recent(self.settings, self.project_path) + self._rebuild_recent_menu() + self._update_window_title() + self._save_bundle(self.project_path) + + self._close_current_project(_migrate) + + def open_project_dialog(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "Open project", "", project_io.PROJECT_FILTER) + if path: + self.open_project(path) + + # -- save ----------------------------------------------------------------- + + def save_project(self) -> None: + if not self.project_path: + self.save_project_as_dialog() + return + if self.is_busy(): + QMessageBox.warning(self, "Busy", "Finish or cancel the current job before saving.") + return + self._save_bundle(self.project_path) + + def save_project_as(self, path: str) -> None: + """Save As keeps the project dir (it's keyed by `project_id`), so no + `audio_path` changes; only `source_path` moves.""" + if self.is_busy(): + QMessageBox.warning(self, "Busy", "Finish or cancel the current job before saving.") + return + self.project_path = os.path.abspath(project_io.bundle_path_for(path)) + project_io.remember_recent(self.settings, self.project_path) + self._rebuild_recent_menu() + self._update_window_title() + self._save_bundle(self.project_path) + + def _save_as_path_dialog(self) -> str | None: + start = self.project_path or "" + path, _ = QFileDialog.getSaveFileName(self, "Save project as", start, project_io.PROJECT_FILTER) + return os.path.abspath(project_io.bundle_path_for(path)) if path else None + + def save_project_as_dialog(self) -> None: + path = self._save_as_path_dialog() + if path: + self.save_project_as(path) + + def _save_bundle(self, path: str, then=None) -> None: + """Save (Claude/old/PLAN_tbaw_bundle.md section 3): the document and + assets are planned on this thread (a snapshot), the zip is written + on a background thread behind `is_busy`, and the session record is + written back here. `then()` runs after a successful save.""" + if self._save_timer.isActive(): + self.save_settings() + try: + plan, warnings = project_io.plan_save( + self.document, self.project_settings, path, self.project_dir, self.project_id, + self._backend_for, FX_PRESETS_DIR, project_io.read_session(self.project_dir), self._project_manifest, + ) + except Exception as e: # noqa: BLE001 - surfaced, never a crash + self.set_status(f"Save failed: {e}", "error") + return + for warning in warnings: + self.set_status(f"Save: {warning}", "warning") + known_ids = list(engine_registry.list_engines()) + self._begin_project_io(f"Saving {project_io.project_title(path)}...", read_only=False) + + def _work(): + return project_io.write_bundle(plan, known_ids, progress=self._io_progress("Writing bundle")) + + def _done(result, error): + self._end_project_io(read_only=False) + if error is not None: + self.set_status(f"Save failed: {error}", "error") + return + try: + project_io.record_save(self.project_dir, path, result) + except OSError as e: + self.set_status(f"Saved, but couldn't record the session: {e}", "warning") + self._project_manifest = dict(plan.manifest) + self._project_dirty = False + self._update_window_title() + self.set_status(f"Saved {project_io.project_title(path)}.", "success") + if then is not None: + then() + + self._run_project_io(_work, _done) + + # -- background I/O plumbing ------------------------------------------------ + + def _begin_project_io(self, status: str, read_only: bool) -> None: + self.set_ui_state(True) + self.set_status(status, "busy") + self.transport_dock.set_progress(0, "") + if read_only and self.editor is not None: + self.editor.setReadOnly(True) + + def _end_project_io(self, read_only: bool) -> None: + if read_only and self.editor is not None: + self.editor.setReadOnly(False) + self.set_ui_state(False) + + def _io_progress(self, label: str): + def _progress(done, total): + percent = (done / total * 100.0) if total else 100.0 + self.projectIoProgress.emit(percent, f"{label} {int(percent)}%") + + return _progress + + def _on_project_io_progress(self, percent: float, detail: str) -> None: + self.transport_dock.set_progress(percent, detail) + + def _run_project_io(self, work, done) -> None: + """`work()` on a plain thread (independent of the engine's worker + loop, so an engine switch mid-save can't strand it); `done(result, + error)` back on the GUI thread.""" + + def _target(): + try: + result = work() + self._projectIoFinished.emit((done, result, None)) + except BaseException as e: # noqa: BLE001 - delivered to the GUI thread + self._projectIoFinished.emit((done, None, e)) + + self._io_thread = threading.Thread(target=_target, name="project-io", daemon=True) + self._io_thread.start() + + def _on_project_io_finished(self, payload) -> None: + done, result, error = payload + self._io_thread = None + done(result, error) + + def wait_for_project_io(self, timeout_s: float = 60.0) -> None: + """Blocks until the background Open/Save (if any) has finished and + its completion has run on this thread. For tests and scripts.""" + deadline = time.time() + timeout_s + while self._io_thread is not None and time.time() < deadline: + thread = self._io_thread + if thread is not None: + thread.join(0.02) + QApplication.processEvents() + QApplication.processEvents() + + def import_text_dialog(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "Import text", filter="Documents (*.txt *.pdf *.epub)") + if path: + self.import_text(path) + + def import_text(self, path: str, target: str | None = None) -> None: + """WF10: prompts "Add to current project" / "New project" unless + `target` ("add" | "new") is given.""" + try: + text = self.engine.extract_text_from_file(path) + except Exception as e: + QMessageBox.critical(self, "Import failed", f"Read failed: {e}") + return + if not text: + QMessageBox.warning(self, "Empty", "No text found in that file.") + return + if target is None: + box = QMessageBox(self) + box.setWindowTitle("Import text") + box.setText("Add the text to the current project, or start a new project from it?") + add_btn = box.addButton("Add to current project", QMessageBox.ButtonRole.AcceptRole) + new_btn = box.addButton("New project", QMessageBox.ButtonRole.ActionRole) + box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + box.exec() + clicked = box.clickedButton() + if clicked is add_btn: + target = "add" + elif clicked is new_btn: + target = "new" + else: + return + if target == "new": + self.new_project() + editor = self.editor + cursor = editor.textCursor() + # A real editor insert: goes through contentsChange -> replace_text + # and lands on the native undo stack like a paste would. The edit + # block keeps Qt from coalescing it with whatever was typed just + # before, so one Ctrl+Z removes exactly the import. + cursor.beginEditBlock() + cursor.insertText(text) + cursor.endEditBlock() + editor.setTextCursor(cursor) + self.set_status(f"Imported {os.path.basename(path)}.") + + def export_dialog(self) -> None: + dialog = ExportDialog(self) + if dialog.exec() != ExportDialog.DialogCode.Accepted: + return + run_export(self, dialog.values(), parent=self, bundle=dialog.bundle_values()) + + def _on_export_progress(self, percent: float, detail: str) -> None: + self.transport_dock.set_progress(percent, detail) + + def _on_export_finished(self, success: bool, message: str) -> None: + self.transport_dock.set_busy(False) + self.transport_dock.set_progress_value(100 if success else 0) + self.set_status(message, "success" if success else "error") + + def project_sample_rate(self) -> int: + """The mix rate for transport and export: the active engine's + output rate (44.1k for Audio8, 24k otherwise). Clips rendered at + another rate are resampled once on load.""" + return int(getattr(self.engine, "SAMPLE_RATE", 24000) or 24000) + + # --- Edit menu ------------------------------------------------------------ + + def open_characters_dialog(self) -> None: + dialog = CharactersDialog(self) + dialog.exec() + + def on_characters_changed(self) -> None: + if self.editor is not None: + self.editor.rehighlight() + if self.transcript_dock is not None: + self.transcript_dock.refresh_character_choices() + self.refresh_timeline() + self.schedule_save() + + # --- engine callbacks (queued automatically across threads - see signals.py) - + + def on_engine_status(self, msg: str, is_error: bool) -> None: + self.set_status(msg, "error" if is_error else "info") + if is_error and "pip install" in msg: + QMessageBox.critical(self, "Missing Dependencies", msg) + + def on_engine_progress(self, percent: float, elapsed: float, eta: str, detail: str) -> None: + self.transport_dock.set_progress(percent, detail, elapsed=elapsed, eta=eta) + + def on_engine_finish(self) -> None: + self.set_ui_state(False) + self._rebuild_transport_schedule() + + def set_ui_state(self, is_running: bool) -> None: + self.transport_dock.set_busy(is_running) + threads_widget = self.settings_dock.schema_form.widget_for("num_threads") + if threads_widget is not None: + threads_widget.setEnabled(not is_running) + self.settings_dock.volume_spin.setEnabled(not is_running) + self.settings_dock.pitch_spin.setEnabled(not is_running) + if not is_running: + self.transport_dock.set_progress_value(0 if self.engine.cancel_event.is_set() else 100) + + # --- preview ----------------------------------- + + def preview_conversion(self) -> None: + if not self.engine.pipeline: + QMessageBox.information(self, "Wait", "Engine is initializing... please wait 2 seconds and try again.") + return + + editor = self.editor + cursor = editor.textCursor() + text_data = cursor.selectedText().replace("
", "\n") if cursor.hasSelection() else editor.toPlainText().strip() + if not text_data: + text_data = ("This is a sample audio preview using the Koh-koh-ro Tea-Tea-S engine. " + "It demonstrates the voice quality and speed settings.") + preview_text = text_data[:1000] + + state = self.settings_dock.get_state() + extra_config = { + "volume": state["volume"], + "pitch": state["pitch"], + "normalize": state["normalize"], + "trim_silence": state["trim_silence"], + "lexicon": self.settings.get("lexicon", {}), + } + if self.settings_dock.apply_fx_enabled(): + extra_config.update(self.fx_dock.project_fx_state()) + + tmp_path = os.path.join(tempfile.gettempdir(), "kokoro_preview.wav") + self.set_status("Generating preview...", "busy") + + def _done(future): + try: + success = future.result() + payload = tmp_path if success else "Preview failed." + except Exception as e: + success = False + payload = f"Preview error: {e}" + self.previewFinished.emit(success, payload) + + future = self.engine.worker.run_coro( + self.engine.generate_preview(preview_text, state["voice"], state["speed"], tmp_path, + extra_config, lang_code=state["lang_code"]) + ) + future.add_done_callback(_done) + + def _on_preview_finished(self, success: bool, payload: str) -> None: + if success: + self.set_status("Playing preview...", "success") + playback.play(payload) + QTimer.singleShot(3000, lambda: self.set_status("Ready")) + else: + self.set_status(payload, "error") + + # --- start/cancel ------------------------------ + + def on_generate_clicked(self) -> None: + """The Transport dock's Generate button. A document with no clips + yet falls back to whole-document `start_conversion()`; a document + with clips dispatches the dirty-scoped batch path.""" + if not self.document.clips: + self.start_conversion() + return + + dirty = self.document.dirty_clips() + if not dirty: + QMessageBox.information(self, "Up to date", "All clips are already generated.") + return + + # Once a document has any clips, Generate always runs the + # dirty-scoped batch path - even with JIT enabled (JIT has no + # per-clip output shape; it stays reachable via the no-clips + # fallback above). + self.timeline_dock.generate_dirty_clips_requested() + + def generate_clip(self, clip_id: str) -> None: + """UI3: the gutter's per-clip play button and the timeline's + context menu both land here. On a clip that is already clean the + request means "regenerate": the engine bumps the clip's take and + writes fresh audio under a new key instead of serving the cached + file (grill TB8).""" + clip = self.document.get_clip(clip_id) + regenerate = clip is not None and clip not in self.document.dirty_clips() + self.timeline_dock.on_generate_clip_requested(clip_id, regenerate=regenerate) + + def on_batch_generation_progress(self, completed: int, total: int, current_clip_label: str) -> None: + percent = int((completed / total) * 100) if total else 0 + if current_clip_label: + detail = f"Generated {completed}/{total} clips" + else: + detail = f"Generating {total} clip(s)..." + self.transport_dock.set_progress(percent, detail) + + def on_batch_generation_finished(self, succeeded: int, failed: int, failed_clip_ids: list) -> None: + total = succeeded + failed + if failed == 0: + self.set_status(f"Generated {succeeded} clip(s).", "success") + elif succeeded == 0: + self.set_status(f"Batch generation failed for all {failed} clip(s).", "error") + else: + self.set_status(f"Generated {succeeded} of {total} clips ({failed} failed)", "warning") + self._rebuild_transport_schedule() + + def auto_split_and_generate(self) -> None: + """Generate menu > "Auto-split then generate": turns every + `[Speaker:FX]:`-tagged span (and, with "Split by paragraph" on, + each span's paragraphs) into clips, then batch-generates them.""" + if self.is_busy(): + QMessageBox.warning(self, "Busy", "Finish or cancel the current job before auto-splitting.") + return + + triples, unmatched = plan_auto_split_clips( + self.document, split_by_paragraph=self.settings.get("auto_split_by_paragraph", False) + ) + + if unmatched: + names = ", ".join(sorted(set(unmatched))) + QMessageBox.warning( + self, "Unmatched speaker names", + f"No character found for: {names}. Those blocks were skipped.", + ) + + if not triples: + QMessageBox.information(self, "Nothing to split", "No taggable text found to auto-split.") + return + + for start, end, character_id in triples: + self.document.undo_stack.push(AssignCharacterCommand(start, end, character_id)) + + self.editor.rehighlight() + self.schedule_save() + self.refresh_timeline() + + self.timeline_dock.generate_dirty_clips_requested() + + def start_conversion(self) -> None: + text_data = self.editor.toPlainText().strip() + if not text_data: + QMessageBox.warning(self, "Empty", "No text to process.") + return + + if not self.engine.pipeline: + QMessageBox.information(self, "Wait", "Engine is initializing... please wait 2 seconds and try again.") + return + + config = self._assemble_config() + + self.set_ui_state(True) + self.transport_dock.set_progress(0, "") + + if self.jit_enabled and self.backend.capabilities.supports_jit_streaming: + self.engine.start_jit_conversion(text_data, config) + else: + self.engine.start_conversion(text_data, config) + + def cancel_conversion(self) -> None: + self.engine.cancel() + self.set_status("Cancelling... waiting for workers...", "warning") + + # --- transport / playhead (section 5) ---------------------------------- + + def current_arrangement(self): + if self._arrangement is None: + self._arrangement = self.build_arrangement() + return self._arrangement + + def _rebuild_transport_schedule(self) -> None: + self._arrangement = self.build_arrangement() + rate = self.project_sample_rate() + schedule = [] + for placed in self._arrangement.placed: + if placed.estimated: + continue + post_config = self.post_config_for_clip(placed.clip) + # One ScheduledClip per segment so multi-segment clips play + # back to back at their real (rendered) offsets. + offset = placed.start_s + for segment in sorted(placed.clip.segments, key=lambda s: s.order_index): + if not segment.audio_path: + continue + schedule.append(ScheduledClip(clip_id=placed.clip.id, start_s=offset, path=segment.audio_path, + post_config=post_config)) + try: + offset += post.rendered_duration_s(segment.audio_path, post_config, rate) + except Exception: + offset += segment.duration or 0.0 + self.transport.load(schedule, sample_rate=self.project_sample_rate(), + total_duration_s=self._arrangement.total_duration_s) + if self.timeline_dock is not None: + self.timeline_dock.timeline_view.set_arrangement(self._arrangement) + self.transport_dock.set_position(self.transport.position(), self.transport.duration()) + + def _on_transport_position(self, seconds: float) -> None: + self.transport_dock.set_position(seconds, self.transport.duration()) + if self.timeline_dock is not None: + self.timeline_dock.timeline_view.set_playhead(seconds) + arrangement = self.current_arrangement() + playing = None + if self.transport.is_playing: + hits = arrangement.at_time(seconds) + playing = hits[0].clip.id if hits else None + self.selection.set_playing_clip(playing) + + def _on_transport_state(self, state: str) -> None: + self.transport_dock.set_playing(state == "playing") + if state != "playing": + self.selection.set_playing_clip(None) + + def _on_loop_toggled(self, checked: bool) -> None: + self.transport.loop = checked + + # --- undo/redo ----------------------------------------------------------- + + def undo(self) -> None: + self.editor.undo_coordinator.undo() + + def redo(self) -> None: + self.editor.undo_coordinator.redo() + + # --- lifecycle ----------------------------------------------------------- + + def closeEvent(self, event) -> None: + """Grill TB12: a dirty project asks Save / Discard / Cancel. Save runs + in the background and closes the window when it succeeds; a failure + keeps the window open with the error. Then close-time GC, and + eviction of every project dir but the one launch resumes (TB13).""" + try: + self.transport.stop() + except Exception: + pass + if self._closed: + super().closeEvent(event) + return + if self._io_thread is not None and not self._closing_after_save: + event.ignore() + return + self.save_settings() + if self.project_dir and not self._closing_after_save and self._project_dirty: + choice = self._ask_close_choice() + if choice == "cancel": + event.ignore() + return + if choice == "save": + path = self.project_path or self._save_as_path_dialog() + if not path: + event.ignore() + return + if not self.project_path: + self.project_path = path + project_io.remember_recent(self.settings, path) + event.ignore() + + def _then(): + self._closing_after_save = True + self.close() + + self._save_bundle(path, then=_then) + return + self._teardown_project(discard=True) + keep = None + last = self.settings.get("last_project") + if self.project_dir and last and self.project_path and os.path.abspath(last) == self.project_path: + keep = self.project_dir + if self.project_dir: + self._teardown_project(discard=False) + try: + project_io.evict_project_dirs(keep) + except OSError: + pass + qt_settings.save_settings(CONFIG_FILE, self.settings) + self._closed = True + for backend in self._asset_backends.values(): + try: + backend.engine.worker.stop() + except Exception: + pass + self._asset_backends.clear() + super().closeEvent(event) diff --git a/kokoro_gui/qt/assets/check.svg b/kokoro_gui/qt/assets/check.svg new file mode 100644 index 0000000..6af6217 --- /dev/null +++ b/kokoro_gui/qt/assets/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/kokoro_gui/qt/assets/chevron_down_dark.svg b/kokoro_gui/qt/assets/chevron_down_dark.svg new file mode 100644 index 0000000..4851968 --- /dev/null +++ b/kokoro_gui/qt/assets/chevron_down_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/kokoro_gui/qt/assets/chevron_down_light.svg b/kokoro_gui/qt/assets/chevron_down_light.svg new file mode 100644 index 0000000..b463a58 --- /dev/null +++ b/kokoro_gui/qt/assets/chevron_down_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/kokoro_gui/qt/assets/chevron_down_on_accent.svg b/kokoro_gui/qt/assets/chevron_down_on_accent.svg new file mode 100644 index 0000000..f3d1e33 --- /dev/null +++ b/kokoro_gui/qt/assets/chevron_down_on_accent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/kokoro_gui/qt/assets/chevron_up_dark.svg b/kokoro_gui/qt/assets/chevron_up_dark.svg new file mode 100644 index 0000000..718d06c --- /dev/null +++ b/kokoro_gui/qt/assets/chevron_up_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/kokoro_gui/qt/assets/chevron_up_light.svg b/kokoro_gui/qt/assets/chevron_up_light.svg new file mode 100644 index 0000000..6e581fe --- /dev/null +++ b/kokoro_gui/qt/assets/chevron_up_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/kokoro_gui/qt/characters_dialog.py b/kokoro_gui/qt/characters_dialog.py new file mode 100644 index 0000000..a581508 --- /dev/null +++ b/kokoro_gui/qt/characters_dialog.py @@ -0,0 +1,241 @@ +"""Edit > Characters... - the character library dialog (UI13: name, color, +voice, FX preset; the Audio8 reference-pair picker stays in the Voice +Reference dock until the WF4 global library exists). + +Edits `app.document.characters` directly. Adding a character also adds a +`Track` for it (Q8's auto-placement default, same as migration.py does). +Removing one is refused while any clip still uses it. + +`apply_changes()` is separated from the widgets so tests can drive the +dialog without `exec()`. +""" +from __future__ import annotations + +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QColorDialog, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, QLineEdit, + QListWidget, QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QWidget, +) + +from kokoro_gui.daw.models import DEFAULT_HIGHLIGHT_PALETTE, Character, Track +from kokoro_gui.qt.fx_presets import list_fx_preset_names + +_FX_NONE = "(none)" + + +class CharactersDialog(QDialog): + def __init__(self, app, parent=None): + super().__init__(parent or app) + self.app = app + self.setWindowTitle("Characters") + self.resize(560, 360) + self._current: Character | None = None + self._loading = False + + root = QHBoxLayout(self) + + left = QVBoxLayout() + self.list = QListWidget() + self.list.currentItemChanged.connect(self._on_current_changed) + left.addWidget(self.list, 1) + btn_row = QHBoxLayout() + self.add_btn = QPushButton("Add") + self.add_btn.clicked.connect(self.add_character) + self.remove_btn = QPushButton("Remove") + self.remove_btn.clicked.connect(self.remove_current) + btn_row.addWidget(self.add_btn) + btn_row.addWidget(self.remove_btn) + left.addLayout(btn_row) + root.addLayout(left, 1) + + right = QWidget() + form = QFormLayout(right) + self.name_edit = QLineEdit() + self.name_edit.textEdited.connect(self._on_name_edited) + form.addRow("Name:", self.name_edit) + + color_row = QHBoxLayout() + self.color_btn = QPushButton() + self.color_btn.setFixedWidth(60) + self.color_btn.clicked.connect(self._pick_color) + self.color_edit = QLineEdit() + self.color_edit.setPlaceholderText("#rrggbb") + self.color_edit.editingFinished.connect(self._on_color_edited) + color_row.addWidget(self.color_btn) + color_row.addWidget(self.color_edit, 1) + form.addRow("Color:", color_row) + + self.voice_combo = QComboBox() + self.voice_combo.setEditable(True) + for voice in self.app.get_all_voices(): + self.voice_combo.addItem(voice) + self.voice_combo.currentTextChanged.connect(self._on_voice_changed) + form.addRow("Voice:", self.voice_combo) + + self.fx_combo = QComboBox() + self.fx_combo.addItem(_FX_NONE) + for name in list_fx_preset_names(self.app.project_dir): + self.fx_combo.addItem(name) + self.fx_combo.currentTextChanged.connect(self._on_fx_changed) + form.addRow("FX preset:", self.fx_combo) + + self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + self.buttons.rejected.connect(self.accept) + self.buttons.accepted.connect(self.accept) + form.addRow(self.buttons) + root.addWidget(right, 2) + + self.reload() + + # -- list ------------------------------------------------------------------ + + def reload(self) -> None: + self._loading = True + try: + self.list.clear() + for character in self.app.document.characters: + item = QListWidgetItem(character.name) + item.setData(0x0100, character.id) + item.setForeground(QColor(character.highlight_color)) + self.list.addItem(item) + finally: + self._loading = False + if self.list.count(): + self.list.setCurrentRow(0) + else: + self._show(None) + + def _on_current_changed(self, current, _previous) -> None: + if self._loading: + return + cid = current.data(0x0100) if current is not None else None + self._show(self.app.document.get_character(cid) if cid else None) + + def _show(self, character: Character | None) -> None: + self._current = character + self._loading = True + try: + enabled = character is not None + for w in (self.name_edit, self.color_btn, self.color_edit, self.voice_combo, self.fx_combo, self.remove_btn): + w.setEnabled(enabled) + if character is None: + self.name_edit.clear() + self.color_edit.clear() + self.color_btn.setStyleSheet("") + return + self.name_edit.setText(character.name) + self._set_color_widgets(character.highlight_color) + voice = character.preset_data.get("voice", "") + if voice and self.voice_combo.findText(voice) < 0: + self.voice_combo.addItem(voice) + self.voice_combo.setCurrentText(voice or "") + fx = character.preset_data.get("fx_preset") or _FX_NONE + if fx == "Select FX Preset...": + fx = _FX_NONE + if self.fx_combo.findText(fx) < 0: + self.fx_combo.addItem(fx) + self.fx_combo.setCurrentText(fx) + finally: + self._loading = False + + def _set_color_widgets(self, color: str) -> None: + self.color_edit.setText(color) + self.color_btn.setStyleSheet(f"background-color: {color};") + + # -- edits ------------------------------------------------------------------- + + def _on_name_edited(self, text: str) -> None: + if self._loading or self._current is None: + return + self._current.name = text + item = self.list.currentItem() + if item is not None: + item.setText(text) + for track in self.app.document.tracks: + if track.character_id == self._current.id: + track.name = text + self._changed() + + def _pick_color(self) -> None: + if self._current is None: + return + for i, preset in enumerate(DEFAULT_HIGHLIGHT_PALETTE): + QColorDialog.setCustomColor(i, QColor(preset)) + color = QColorDialog.getColor(QColor(self._current.highlight_color), self, "Highlight color") + if color.isValid(): + self.set_color(color.name()) + + def _on_color_edited(self) -> None: + if self._loading or self._current is None: + return + text = self.color_edit.text().strip() + if QColor(text).isValid(): + self.set_color(QColor(text).name()) + + def set_color(self, color: str) -> None: + if self._current is None: + return + self._current.highlight_color = color + self._set_color_widgets(color) + item = self.list.currentItem() + if item is not None: + item.setForeground(QColor(color)) + self._changed() + + def _on_voice_changed(self, text: str) -> None: + if self._loading or self._current is None: + return + if text: + self._current.preset_data["voice"] = text + else: + self._current.preset_data.pop("voice", None) + self._changed() + + def _on_fx_changed(self, text: str) -> None: + if self._loading or self._current is None: + return + if text and text != _FX_NONE: + self._current.preset_data["fx_preset"] = text + else: + self._current.preset_data.pop("fx_preset", None) + self._changed() + + def add_character(self) -> Character: + doc = self.app.document + index = len(doc.characters) + color = DEFAULT_HIGHLIGHT_PALETTE[index % len(DEFAULT_HIGHLIGHT_PALETTE)] + base = "Character" + names = {c.name for c in doc.characters} + name = base + n = 2 + while name in names: + name = f"{base} {n}" + n += 1 + character = Character.from_preset_dict(name, {"voice": self.app.settings.get("voice", "af_heart")}, + highlight_color=color, backend_id=self.app.backend.id) + doc.characters.append(character) + doc.tracks.append(Track(name=name, character_id=character.id, order_index=len(doc.tracks))) + self.reload() + self.list.setCurrentRow(self.list.count() - 1) + self._changed() + return character + + def remove_current(self) -> None: + character = self._current + if character is None: + return + doc = self.app.document + in_use = [c for c in doc.clips if c.character_id == character.id] + if in_use: + QMessageBox.warning(self, "In use", + f"{character.name} is used by {len(in_use)} clip(s). Reassign them first.") + return + doc.characters = [c for c in doc.characters if c.id != character.id] + doc.tracks = [t for t in doc.tracks if t.character_id != character.id] + for i, track in enumerate(sorted(doc.tracks, key=lambda t: t.order_index)): + track.order_index = i + self.reload() + self._changed() + + def _changed(self) -> None: + self.app.on_characters_changed() diff --git a/kokoro_gui/qt/docks/__init__.py b/kokoro_gui/qt/docks/__init__.py new file mode 100644 index 0000000..e164c09 --- /dev/null +++ b/kokoro_gui/qt/docks/__init__.py @@ -0,0 +1,13 @@ +from .transcript_dock import TranscriptDock +from .settings_dock import SettingsDock +from .fx_dock import FXDock +from .mixing_dock import MixingDock +from .lexicon_dock import LexiconDock +from .voice_clone_dock import VoiceCloneDock +from .timeline_dock import TimelineDock +from .transport_dock import TransportDock + +__all__ = [ + "TranscriptDock", "SettingsDock", "FXDock", "MixingDock", "LexiconDock", "VoiceCloneDock", + "TimelineDock", "TransportDock", +] diff --git a/kokoro_gui/qt/docks/export_dialog.py b/kokoro_gui/qt/docks/export_dialog.py new file mode 100644 index 0000000..1cc2eac --- /dev/null +++ b/kokoro_gui/qt/docks/export_dialog.py @@ -0,0 +1,200 @@ +"""File > Export... (section 6 of Claude/PLAN_ui_shell_redesign.md). + +Holds what left the Settings tab: output folder, base filename, format, +"also write .srt", "keep per-clip files". Values persist per project in +`app.project_settings["export"]`, falling back to the old `config_qt.json` +keys (`out_dir`/`filename`/`format`/`export_subtitles`/`separate`) so an +existing user's choices carry over. + +Also the project bundle's two options (grill TB14): whether generated +audio goes into the `.tbaw` and in which format (wav or flac for new +segments). They live in `app.project_settings["bundle"]` +(`kokoro_gui.qt.project.bundle_options`) and are applied on OK, before the +dirty-clips prompt. + +`run_export()` refuses (with the count) while any clip is dirty, offering +"Generate first" / "Export anyway", then schedules `mixdown()` on the +engine worker via `run_coro` and reports through the Transport dock's +progress bar. A document with no clips at all still gets the whole-text +`start_conversion()` path - that's unchanged, this dialog is for clip +documents. +""" +from __future__ import annotations + +import asyncio +import os + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QHBoxLayout, QLineEdit, + QMessageBox, QPushButton, QWidget, +) + +from kokoro_gui.daw.mixdown import mixdown +from kokoro_gui.qt import project as project_io + +FORMATS = ("wav", "mp3", "flac", "ogg") +BUNDLE_AUDIO_FORMATS = ("wav", "flac") + + +def export_defaults(app) -> dict: + project = dict(app.project_settings.get("export", {})) if isinstance(app.project_settings, dict) else {} + return { + "out_dir": project.get("out_dir", app.settings.get("out_dir", "audio_output")), + "filename": project.get("filename", app.settings.get("filename", "output")), + "format": project.get("format", app.settings.get("format", "wav")), + "srt": bool(project.get("srt", app.settings.get("export_subtitles", False))), + "keep_clip_files": bool(project.get("keep_clip_files", app.settings.get("separate", False))), + } + + +class ExportDialog(QDialog): + exportFinished = Signal(bool, str) + + def __init__(self, app, parent=None): + super().__init__(parent or app) + self.app = app + self.setWindowTitle("Export") + values = export_defaults(app) + + form = QFormLayout(self) + dir_row = QWidget() + dir_layout = QHBoxLayout(dir_row) + dir_layout.setContentsMargins(0, 0, 0, 0) + self.out_dir_edit = QLineEdit(values["out_dir"]) + browse = QPushButton("...") + browse.clicked.connect(self._browse_dir) + dir_layout.addWidget(self.out_dir_edit, 1) + dir_layout.addWidget(browse) + form.addRow("Output folder:", dir_row) + + self.filename_edit = QLineEdit(values["filename"]) + form.addRow("Base filename:", self.filename_edit) + + self.format_combo = QComboBox() + self.format_combo.addItems(FORMATS) + self.format_combo.setCurrentText(values["format"] if values["format"] in FORMATS else "wav") + form.addRow("Format:", self.format_combo) + + self.srt_check = QCheckBox("Also write .srt subtitles") + self.srt_check.setChecked(values["srt"]) + form.addRow("", self.srt_check) + + self.keep_clips_check = QCheckBox("Keep per-clip files next to the mixdown") + self.keep_clips_check.setChecked(values["keep_clip_files"]) + form.addRow("", self.keep_clips_check) + + bundle = project_io.bundle_options(app.project_settings) + self.bundle_audio_check = QCheckBox("Bundle generated audio in the project file") + self.bundle_audio_check.setChecked(bool(bundle["include_generated_audio"])) + self.bundle_audio_check.setToolTip("Off gives a small .tbaw whose every clip regenerates on open.") + form.addRow("Project:", self.bundle_audio_check) + self.bundle_format_combo = QComboBox() + self.bundle_format_combo.addItems(BUNDLE_AUDIO_FORMATS) + self.bundle_format_combo.setCurrentText(bundle["audio_format"]) + self.bundle_format_combo.setToolTip("Format of newly generated segments; existing ones keep theirs.") + form.addRow("Bundle audio format:", self.bundle_format_combo) + + self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Export") + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + form.addRow(self.buttons) + + def _browse_dir(self) -> None: + d = QFileDialog.getExistingDirectory(self, "Select output folder", self.out_dir_edit.text()) + if d: + self.out_dir_edit.setText(d) + + def bundle_values(self) -> dict: + return { + "include_generated_audio": self.bundle_audio_check.isChecked(), + "include_imported_audio": True, + "audio_format": self.bundle_format_combo.currentText(), + } + + def values(self) -> dict: + return { + "out_dir": self.out_dir_edit.text().strip() or "audio_output", + # basename(): the free-text filename is a path sink, same + # sanitization _assemble_config applies. + "filename": os.path.basename(self.filename_edit.text().strip()) or "output", + "format": self.format_combo.currentText(), + "srt": self.srt_check.isChecked(), + "keep_clip_files": self.keep_clips_check.isChecked(), + } + + +def run_export(app, values: dict, parent=None, bundle: dict | None = None) -> bool: + """Validates, remembers `values` (and the `bundle` options, if given) in + the project, and schedules the mixdown. Returns False when nothing was + scheduled.""" + parent = parent or app + document = app.document + if bundle is not None: + app.project_settings["bundle"] = project_io.bundle_options({"bundle": bundle}) + app.schedule_save() + if not document.clips: + QMessageBox.information(parent, "Nothing to export", + "This project has no clips yet. Assign characters to text and generate first.") + return False + if app.transport_dock.is_busy(): + QMessageBox.warning(parent, "Busy", "Finish or cancel the current job before exporting.") + return False + + dirty = document.dirty_clips() + if dirty: + box = QMessageBox(parent) + box.setWindowTitle("Clips out of date") + box.setText(f"{len(dirty)} clip(s) are out of date and will be silent in the export.") + generate_btn = box.addButton("Generate first", QMessageBox.ButtonRole.AcceptRole) + box.addButton("Export anyway", QMessageBox.ButtonRole.ActionRole) + box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + box.exec() + clicked = box.clickedButton() + if clicked is generate_btn: + app.on_generate_clicked() + return False + if clicked is None or box.buttonRole(clicked) == QMessageBox.ButtonRole.RejectRole: + return False + + app.project_settings["export"] = dict(values) + app.schedule_save() + + out_path = os.path.join(values["out_dir"], f"{values['filename']}.{values['format']}") + arrangement = app.build_arrangement() + sample_rate = app.project_sample_rate() + # Resolved on the GUI thread (it reads dock state); the export thread + # only applies them. + post_configs = {p.clip.id: app.post_config_for_clip(p.clip) for p in arrangement.placed} + + app.transport_dock.set_busy(True) + app.transport_dock.set_status("Exporting...", "busy") + app.transport_dock.set_progress(0, "") + + def _progress(fraction: float, detail: str) -> None: + app.exportProgress.emit(fraction * 100.0, detail) + + async def _run(): + return await asyncio.to_thread( + mixdown, document, out_path, values["format"], sample_rate, + values["srt"], values["keep_clip_files"], arrangement, app.backend.id, _progress, + lambda clip: post_configs.get(clip.id), + ) + + def _done(future): + try: + result = future.result() + extras = [] + if result.srt_path: + extras.append("srt") + if result.clip_files: + extras.append(f"{len(result.clip_files)} clip files") + suffix = f" (+ {', '.join(extras)})" if extras else "" + app.exportFinished.emit(True, f"Exported {result.audio_path}{suffix}") + except Exception as e: # noqa: BLE001 - surfaced to the status line + app.exportFinished.emit(False, f"Export failed: {e}") + + future = app.engine.worker.run_coro(_run()) + future.add_done_callback(_done) + return True diff --git a/kokoro_gui/qt/docks/fx_dock.py b/kokoro_gui/qt/docks/fx_dock.py new file mode 100644 index 0000000..c7377cc --- /dev/null +++ b/kokoro_gui/qt/docks/fx_dock.py @@ -0,0 +1,411 @@ +"""Audio FX dock: builds the FX controls from `kokoro_gui.qt.spec.FX_FIELD_SPECS` +and loads/saves FX presets under `presets/fx/`. + +UI6 (Claude/PLAN_ui_shell_redesign.md section 3): the tab follows the +selection the way the Settings tab does, with the same three modes: + +- "none": the project-wide FX state, persisted in `config_qt.json`, fed + into whole-document generation and the bottom layer of every clip's + resolved stack. Edits schedule an autosave and a debounced timeline + re-render. +- "clip": the selected clip's resolved FX (project state, then the + character's preset, then `clip.fx_override` on top). Edits are collected + and pushed as one `SetClipFxCommand` per 300ms of quiet, so a slider drag + is one undo step and the override is a full resolved-values dict. +- "character": the character's attached FX preset file. The first edit per + session asks "This changes the preset for every clip using X. Continue?"; + a character with no preset yet gets one named after it. + +`project_fx_state()` always returns the "none" values regardless of what's +rendered (what `_assemble_config`/`preview_conversion` need); `get_state()` +is whatever the widgets currently show. The preset combo names the preset +the current scope resolves to. Scope resolution is +`kokoro_gui.qt.fx_resolve.resolve_fx`, shared with `_assemble_clip_config`. + +FX are read-time post-processing (kokoro_gui/audio/post.py): every edit here +is audible on the next transport rebuild and never dirties a clip. + +Seven FX_PRESET_KEYS fields have no widget here (see spec.py's docstring); +their values live in `self._hidden_values` and only change via preset load. +""" +from __future__ import annotations + +import json +import os +import re + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDockWidget, QDoubleSpinBox, QFormLayout, + QGroupBox, QHBoxLayout, QInputDialog, QLabel, QMessageBox, QPushButton, + QScrollArea, QVBoxLayout, QWidget, +) + +import kokoro_gui.qt.app as qt_app_module +from kokoro_gui.daw.undo import SetClipFxCommand +from kokoro_gui.engine.presets import ALLOWED_FX_PRESET_KEYS, filter_allowed_keys +from kokoro_gui.qt import fx_resolve, spec +from kokoro_gui.qt.fx_presets import list_fx_preset_names +from kokoro_gui.qt.fx_resolve import PLACEHOLDER as _PLACEHOLDER + +CLIP_EDIT_DEBOUNCE_MS = 300 +PROJECT_EDIT_DEBOUNCE_MS = 300 + + +class FXDock(QDockWidget): + def __init__(self, app, parent=None): + super().__init__("Audio FX", parent) + self.setObjectName("dock_fx") + self.app = app + + self._value_widgets: dict[str, QDoubleSpinBox] = {} + self._enabled_checks: dict[str, QCheckBox] = {} + self._hidden_values: dict[str, float] = { + k: spec.SETTINGS_DEFAULTS[k] for k in spec.FX_KEYS_WITHOUT_WIDGET + } + self._mode = "none" + self._target = None + self._none_values: dict = {k: self.app.settings.get(k, spec.SETTINGS_DEFAULTS[k]) for k in spec.FX_PRESET_KEYS} + self._loading = False + self._character_confirmed: set = set() + + self._clip_timer = QTimer(self) + self._clip_timer.setSingleShot(True) + self._clip_timer.setInterval(CLIP_EDIT_DEBOUNCE_MS) + self._clip_timer.timeout.connect(self._flush_clip_edit) + + # Project-scope edits re-render the timeline/transport (read-time + # FX); debounced so a run of spinbox steps is one re-render. + self._project_timer = QTimer(self) + self._project_timer.setSingleShot(True) + self._project_timer.setInterval(PROJECT_EDIT_DEBOUNCE_MS) + self._project_timer.timeout.connect(self.app.refresh_timeline) + + content = QWidget() + outer = QVBoxLayout(content) + + self.scope_label = QLabel("Project FX") + outer.addWidget(self.scope_label) + + preset_row = QHBoxLayout() + self.preset_combo = QComboBox() + self.preset_combo.activated.connect(self._on_preset_activated) + save_btn = QPushButton("Save FX Preset...") + save_btn.clicked.connect(self._save_preset_dialog) + refresh_btn = QPushButton("Refresh") + refresh_btn.clicked.connect(self.refresh_presets) + preset_row.addWidget(QLabel("FX Preset:")) + preset_row.addWidget(self.preset_combo, 1) + preset_row.addWidget(save_btn) + preset_row.addWidget(refresh_btn) + outer.addLayout(preset_row) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + inner = QWidget() + inner_layout = QVBoxLayout(inner) + scroll.setWidget(inner) + outer.addWidget(scroll) + + specs_by_group: dict[str, list[spec.FXSliderSpec]] = {g: [] for g in spec.FX_GROUP_ORDER} + for s in spec.FX_FIELD_SPECS: + specs_by_group[s.group].append(s) + + for group in spec.FX_GROUP_ORDER: + box = QGroupBox(group.replace("&", "&&")) + box_layout = QVBoxLayout(box) + self._build_group(box_layout, specs_by_group[group]) + for key, label, toggle_group in spec.FX_STANDALONE_TOGGLES: + if toggle_group == group: + check = QCheckBox(label) + check.setChecked(self.app.settings.get(key, False)) + check.toggled.connect(self._on_widget_changed) + self._enabled_checks[key] = check + box_layout.addWidget(check) + inner_layout.addWidget(box) + + inner_layout.addStretch(1) + self.setWidget(content) + + self._loading = True + self.set_values(self.app.settings) + self._loading = False + self.refresh_presets() + self.app.selection.changed.connect(self.refresh_for_selection) + self.refresh_for_selection() + + def _build_group(self, box_layout: QVBoxLayout, specs: list) -> None: + sections: dict[str, list] = {} + section_order: list[str] = [] + for s in specs: + if s.section not in sections: + sections[s.section] = [] + section_order.append(s.section) + sections[s.section].append(s) + + for section in section_order: + section_specs = sections[section] + enabled_key = section_specs[0].enabled_key + if enabled_key: + header = QCheckBox(section) + header.setChecked(self.app.settings.get(enabled_key, False)) + header.toggled.connect(self._on_widget_changed) + self._enabled_checks[enabled_key] = header + box_layout.addWidget(header) + else: + box_layout.addWidget(QLabel(f"{section}")) + + form = QFormLayout() + for s in section_specs: + spin = QDoubleSpinBox() + spin.setRange(s.minimum, s.maximum) + span = s.maximum - s.minimum + spin.setSingleStep(span / s.steps if s.steps else 0.1) + spin.setDecimals(s.decimals) + spin.setSuffix(f" {s.unit}" if s.unit else "") + spin.setValue(self.app.settings.get(s.key, s.minimum)) + spin.valueChanged.connect(self._on_widget_changed) + form.addRow(s.label + ":", spin) + self._value_widgets[s.key] = spin + box_layout.addLayout(form) + + # --- scope --------------------------------------------------------------- + + def _resolve_mode(self): + kind = self.app.selection.kind + if kind == "clip": + clip = self.app.document.get_clip(self.app.selection.selected_clip_id) + if clip is not None: + return "clip", clip + elif kind == "character": + character = self.app.document.get_character(self.app.selection.selected_character_id) + if character is not None: + return "character", character + return "none", None + + @property + def mode(self) -> str: + return self._mode + + def refresh_for_selection(self) -> None: + if self._mode == "none" and not self._loading: + self._none_values = self.get_state() + if self._clip_timer.isActive(): + self._flush_clip_edit() + self._mode, self._target = self._resolve_mode() + self._loading = True + try: + values, preset_name = self._resolved_values() + self.set_values(values) + self._set_combo_text(preset_name) + finally: + self._loading = False + if self._mode == "clip": + self.scope_label.setText("Clip FX (override for the selected clip)") + elif self._mode == "character": + self.scope_label.setText(f"Character FX: {self._target.name}") + else: + self.scope_label.setText("Project FX") + + def _character_preset_name(self, character): + return fx_resolve.real_preset_name(character.preset_data.get("fx_preset")) if character is not None else None + + def _resolved_values(self): + """`(values, preset_name)` for the current scope - the same + resolution `_assemble_clip_config` generates and plays with.""" + if self._mode == "none": + return dict(self._none_values), self.app.settings.get("fx_preset") or None + if self._mode == "character": + resolution = fx_resolve.resolve_fx(self.app, character=self._target) + else: + resolution = fx_resolve.resolve_fx(self.app, clip=self._target) + return resolution.values, resolution.preset_name + + def _set_combo_text(self, name) -> None: + self.preset_combo.blockSignals(True) + try: + if name and self.preset_combo.findText(name) < 0 and name != "custom": + self.preset_combo.addItem(name) + if name == "custom": + if self.preset_combo.findText("(custom)") < 0: + self.preset_combo.addItem("(custom)") + self.preset_combo.setCurrentText("(custom)") + else: + self.preset_combo.setCurrentText(name or _PLACEHOLDER) + finally: + self.preset_combo.blockSignals(False) + + # --- edits ------------------------------------------------------------------- + + def _on_widget_changed(self, *_args) -> None: + if self._loading: + return + if self._mode == "none": + self.app.schedule_save() + self._project_timer.start() + return + if self._mode == "clip": + self._clip_timer.start() + return + self._apply_character_edit() + + def _flush_clip_edit(self) -> None: + self._clip_timer.stop() + if self._mode != "clip" or self._target is None: + return + values = {k: self.get_state()[k] for k in spec.FX_PRESET_KEYS} + self.app.document.undo_stack.push(SetClipFxCommand(self._target.id, values, preset_name=None)) + self._set_combo_text("custom") + self.app.editor.rehighlight() + self.app.schedule_save() + self.app.refresh_timeline() + + def _confirm_character_edit(self, character) -> bool: + if character.id in self._character_confirmed: + return True + answer = QMessageBox.question( + self, "Edit character FX", + f"This changes the preset for every clip using {character.name}. Continue?", + ) + if answer != QMessageBox.StandardButton.Yes: + return False + self._character_confirmed.add(character.id) + return True + + def _apply_character_edit(self) -> None: + character = self._target + if character is None: + return + if not self._confirm_character_edit(character): + self.refresh_for_selection() + return + name = self._character_preset_name(character) + if not name: + name = re.sub(r'[<>:"/\\|?*]', "", character.name).strip() or "character" + character.preset_data["fx_preset"] = name + data = {k: self.get_state()[k] for k in spec.FX_PRESET_KEYS} + if self._write_preset_file(name, data): + self._set_combo_text(name) + self.app.editor.rehighlight() + self.app.schedule_save() + self.app.refresh_timeline() + + # --- state --------------------------------------------------------------- + + def get_state(self) -> dict: + state = dict(self._hidden_values) + for key, spin in self._value_widgets.items(): + state[key] = spin.value() + for key, check in self._enabled_checks.items(): + state[key] = check.isChecked() + return state + + def project_fx_state(self) -> dict: + """The project-wide ("none") FX values regardless of what's rendered.""" + if self._mode == "none": + return self.get_state() + return dict(self._none_values) + + def set_values(self, data: dict) -> None: + was_loading = self._loading + self._loading = True + try: + for key, spin in self._value_widgets.items(): + if key in data: + spin.setValue(data[key]) + for key, check in self._enabled_checks.items(): + if key in data: + check.setChecked(bool(data[key])) + for key in self._hidden_values: + if key in data: + self._hidden_values[key] = data[key] + finally: + self._loading = was_loading + + # --- presets (presets/fx/*.json) -------------------------------------- + + def refresh_presets(self) -> None: + current = self.preset_combo.currentText() + presets = [_PLACEHOLDER] + list_fx_preset_names(self.app.project_dir) + self.preset_combo.blockSignals(True) + self.preset_combo.clear() + self.preset_combo.addItems(presets) + self.preset_combo.setCurrentText(current if current in presets else _PLACEHOLDER) + self.preset_combo.blockSignals(False) + if getattr(self.app, "settings_dock", None) is not None: + self.app.settings_dock.refresh_fx_presets() + if getattr(self.app, "transcript_dock", None) is not None: + self.app.transcript_dock.refresh_fx_choices() + + def _write_preset_file(self, name: str, data: dict) -> bool: + fpath = os.path.join(qt_app_module.FX_PRESETS_DIR, f"{name}.json") + try: + os.makedirs(qt_app_module.FX_PRESETS_DIR, exist_ok=True) + with open(fpath, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=4) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to save FX preset: {e}") + return False + return True + + def _save_preset_dialog(self) -> None: + name, ok = QInputDialog.getText(self, "Save FX Preset", "Enter FX preset name:") + if not ok or not name: + return + name = re.sub(r'[<>:"/\\|?*]', "", name).strip() + if not name: + return + data = {k: self.get_state()[k] for k in spec.FX_PRESET_KEYS} + if self._write_preset_file(name, data): + QMessageBox.information(self, "Saved", f"FX Preset '{name}' saved.") + self.refresh_presets() + self._set_combo_text(name) + + def load_preset(self, name: str) -> None: + """Project-scope load: applies the preset's values to the "none" + state (and the widgets, when that's what's rendered).""" + if not name or name == _PLACEHOLDER: + return + safe_name = os.path.basename(name) + if not safe_name: + return + fpath = os.path.join(qt_app_module.FX_PRESETS_DIR, f"{safe_name}.json") + if not os.path.exists(fpath): + return + try: + with open(fpath, "r", encoding="utf-8") as fh: + data = json.load(fh) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to load FX preset: {e}") + return + self.app.settings["fx_preset"] = safe_name + if self._mode == "none": + self.set_values(data) + self._set_combo_text(safe_name) + else: + self._none_values.update(filter_allowed_keys(data, ALLOWED_FX_PRESET_KEYS)) + if getattr(self.app, "settings_dock", None) is not None: + self.app.settings_dock.set_fx_preset_display(safe_name) + self.app.schedule_save() + self.app.refresh_timeline() + + def _on_preset_activated(self, index: int) -> None: + name = self.preset_combo.itemText(index) + if not name or name == _PLACEHOLDER or name == "(custom)": + return + if self._mode == "clip" and self._target is not None: + self.app.transcript_dock.apply_fx_preset_to_clip(self._target.id, name) + self.refresh_for_selection() + return + if self._mode == "character" and self._target is not None: + self._target.preset_data["fx_preset"] = name + self.app.editor.rehighlight() + self.app.schedule_save() + self.app.refresh_timeline() + self.refresh_for_selection() + return + self.load_preset(name) + + def _on_preset_selected(self, name: str) -> None: + """Kept for callers that used the old currentTextChanged slot.""" + self.load_preset(name) diff --git a/kokoro_gui/qt/docks/lexicon_dock.py b/kokoro_gui/qt/docks/lexicon_dock.py new file mode 100644 index 0000000..7554360 --- /dev/null +++ b/kokoro_gui/qt/docks/lexicon_dock.py @@ -0,0 +1,87 @@ +"""Lexicon dock: find/replace rules stored in `self.app.settings["lexicon"]`. +Saves eagerly (bypasses the debounced autosave every other field uses).""" +from __future__ import annotations + +from PySide6.QtWidgets import ( + QDockWidget, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox, + QPushButton, QScrollArea, QVBoxLayout, QWidget, +) + + +class LexiconDock(QDockWidget): + def __init__(self, app, parent=None): + super().__init__("Lexicon", parent) + self.setObjectName("dock_lexicon") + self.app = app + + content = QWidget() + layout = QVBoxLayout(content) + + add_row = QHBoxLayout() + add_row.addWidget(QLabel("Original Text:")) + self.orig_edit = QLineEdit() + add_row.addWidget(self.orig_edit) + add_row.addWidget(QLabel("Replacement:")) + self.replace_edit = QLineEdit() + add_row.addWidget(self.replace_edit) + add_btn = QPushButton("Add Rule") + add_btn.clicked.connect(self.add_rule) + add_row.addWidget(add_btn) + layout.addLayout(add_row) + + self.list_scroll = QScrollArea() + self.list_scroll.setWidgetResizable(True) + self._list_container = QWidget() + self._list_layout = QVBoxLayout(self._list_container) + self.list_scroll.setWidget(self._list_container) + layout.addWidget(self.list_scroll, 1) + + layout.addWidget(QLabel("Note: Replacements are case-insensitive. Applied before generation.")) + + self.setWidget(content) + self.refresh_list() + + def add_rule(self) -> None: + orig = self.orig_edit.text().strip() + rep = self.replace_edit.text().strip() + if not orig: + QMessageBox.warning(self, "Error", "Original text cannot be empty.") + return + + if "lexicon" not in self.app.settings: + self.app.settings["lexicon"] = {} + self.app.settings["lexicon"][orig] = rep + self.orig_edit.clear() + self.replace_edit.clear() + self.app.save_settings() + self.refresh_list() + + def delete_rule(self, key: str) -> None: + if key in self.app.settings.get("lexicon", {}): + del self.app.settings["lexicon"][key] + self.app.save_settings() + self.refresh_list() + + def refresh_list(self) -> None: + while self._list_layout.count(): + item = self._list_layout.takeAt(0) + w = item.widget() + if w: + w.deleteLater() + + lexicon = self.app.settings.get("lexicon", {}) + if not lexicon: + self._list_layout.addWidget(QLabel("No rules defined.")) + return + + for orig, rep in lexicon.items(): + row = QFrame() + row_layout = QHBoxLayout(row) + row_layout.addWidget(QLabel(orig)) + row_layout.addWidget(QLabel("->")) + row_layout.addWidget(QLabel(rep)) + row_layout.addStretch(1) + del_btn = QPushButton("X") + del_btn.clicked.connect(lambda _c=False, k=orig: self.delete_rule(k)) + row_layout.addWidget(del_btn) + self._list_layout.addWidget(row) diff --git a/kokoro_gui/qt/docks/mixing_dock.py b/kokoro_gui/qt/docks/mixing_dock.py new file mode 100644 index 0000000..2a33fae --- /dev/null +++ b/kokoro_gui/qt/docks/mixing_dock.py @@ -0,0 +1,273 @@ +"""Custom Voice (mixing) dock: blends two voice tensors via +`self.app.engine.mix_voices` and previews/saves the result. Shown only when +`app.backend.capabilities.supports_voice_mixing` is true - see app.py's +`_sync_mixing_dock`. + +Uses the literal relative "custom_voices" path (not +`kokoro_engine.CUSTOM_VOICES_DIR`) - relies on the process cwd for this, +which is why the test fixtures `monkeypatch.chdir(tmp_path)`. +""" +from __future__ import annotations + +import os +import re +import tempfile + +import playback +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QComboBox, QDockWidget, QFrame, QGridLayout, QHBoxLayout, + QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSlider, + QVBoxLayout, QWidget, +) + +from kokoro_gui.qt import spec + +CUSTOM_VOICES_DIR = "custom_voices" + + +class MixingDock(QDockWidget): + previewFinished = Signal(bool, str) + mixFinished = Signal(bool, str) + + def __init__(self, app, parent=None): + super().__init__("Custom Voice", parent) + self.setObjectName("dock_mixing") + self.app = app + self.previewFinished.connect(self._on_preview_finished) + self.mixFinished.connect(self._on_mix_finished) + + content = QWidget() + layout = QVBoxLayout(content) + + lang_items = list(spec.LANGUAGES.items()) + + sel_grid = QGridLayout() + sel_grid.addWidget(QLabel("Voice A:"), 0, 0) + self.lang_a_combo = QComboBox() + self.voice_a_combo = QComboBox() + for label, code in lang_items: + self.lang_a_combo.addItem(label, code) + self.lang_a_combo.currentIndexChanged.connect(lambda _i: self._refresh_voice_list(self.lang_a_combo, self.voice_a_combo)) + sel_grid.addWidget(self.lang_a_combo, 0, 1) + sel_grid.addWidget(self.voice_a_combo, 0, 2) + + sel_grid.addWidget(QLabel("Voice B:"), 1, 0) + self.lang_b_combo = QComboBox() + self.voice_b_combo = QComboBox() + for label, code in lang_items: + self.lang_b_combo.addItem(label, code) + self.lang_b_combo.setCurrentIndex(0) + self.lang_b_combo.currentIndexChanged.connect(lambda _i: self._refresh_voice_list(self.lang_b_combo, self.voice_b_combo)) + sel_grid.addWidget(self.lang_b_combo, 1, 1) + sel_grid.addWidget(self.voice_b_combo, 1, 2) + layout.addLayout(sel_grid) + self._refresh_voice_list(self.lang_a_combo, self.voice_a_combo) + self._refresh_voice_list(self.lang_b_combo, self.voice_b_combo) + if self.voice_b_combo.count() > 1: + self.voice_b_combo.setCurrentIndex(1) + + op_row = QHBoxLayout() + op_row.addWidget(QLabel("Operation:")) + self.op_combo = QComboBox() + self.op_combo.addItems(["mix", "add", "subtract", "multiply", "divide"]) + self.op_combo.currentTextChanged.connect(self._update_ratio_label) + op_row.addWidget(self.op_combo) + op_row.addStretch(1) + layout.addLayout(op_row) + + self.ratio_label = QLabel("Mix: 50% A / 50% B") + layout.addWidget(self.ratio_label) + self.ratio_slider = QSlider(Qt.Orientation.Horizontal) + self.ratio_slider.setRange(0, 100) + self.ratio_slider.setValue(50) + self.ratio_slider.valueChanged.connect(self._update_ratio_label) + layout.addWidget(self.ratio_slider) + self._update_ratio_label() + + prev_row = QHBoxLayout() + prev_row.addWidget(QLabel("Preview Language:")) + self.preview_lang_combo = QComboBox() + for label, code in lang_items: + self.preview_lang_combo.addItem(label, code) + prev_row.addWidget(self.preview_lang_combo) + preview_btn = QPushButton("\U0001F50A Preview") + preview_btn.clicked.connect(self.preview_mix) + prev_row.addWidget(preview_btn) + prev_row.addStretch(1) + layout.addLayout(prev_row) + + save_row = QHBoxLayout() + save_row.addWidget(QLabel("New Voice Name:")) + self.mix_name_edit = QLineEdit() + save_row.addWidget(self.mix_name_edit, 1) + save_btn = QPushButton("Create && Save") + save_btn.clicked.connect(self.mix_voice_action) + save_row.addWidget(save_btn) + layout.addLayout(save_row) + + self.mix_status_label = QLabel("") + layout.addWidget(self.mix_status_label) + + layout.addWidget(QLabel("Custom Voices:")) + self.list_scroll = QScrollArea() + self.list_scroll.setWidgetResizable(True) + self.list_scroll.setFixedHeight(200) + self._list_container = QWidget() + self._list_layout = QVBoxLayout(self._list_container) + self.list_scroll.setWidget(self._list_container) + layout.addWidget(self.list_scroll) + + layout.addStretch(1) + self.setWidget(content) + self.refresh_voice_lists() + + def _ratio_value(self) -> float: + return self.ratio_slider.value() / 100.0 + + def _update_ratio_label(self, *_args) -> None: + p = self.ratio_slider.value() + op = self.op_combo.currentText() + if op == "mix": + self.ratio_label.setText(f"Mix: {100 - p}% A / {p}% B") + elif op == "divide": + self.ratio_label.setText(f"Op: Divide | Influence: {p}% (unstable and VERY LOUD)") + else: + self.ratio_label.setText(f"Op: {op.capitalize()} | Influence: {p}%") + + def _refresh_voice_list(self, lang_combo: QComboBox, voice_combo: QComboBox) -> None: + code = lang_combo.currentData() + voices = self.app.get_all_voices(code) + current = voice_combo.currentText() + voice_combo.blockSignals(True) + voice_combo.clear() + voice_combo.addItems(voices) + if current in voices: + voice_combo.setCurrentText(current) + elif voices: + voice_combo.setCurrentIndex(0) + voice_combo.blockSignals(False) + + def refresh_voice_lists(self) -> None: + if hasattr(self.app, "settings_dock") and self.app.settings_dock is not None: + self.app.settings_dock.refresh_voice_choices() + self._refresh_voice_list(self.lang_a_combo, self.voice_a_combo) + self._refresh_voice_list(self.lang_b_combo, self.voice_b_combo) + + while self._list_layout.count(): + item = self._list_layout.takeAt(0) + w = item.widget() + if w: + w.deleteLater() + + custom = [] + if os.path.exists(CUSTOM_VOICES_DIR): + custom = sorted(f[:-3] for f in os.listdir(CUSTOM_VOICES_DIR) if f.endswith(".pt")) + if not custom: + self._list_layout.addWidget(QLabel("No custom voices found.")) + else: + for cv in custom: + row = QFrame() + row_layout = QHBoxLayout(row) + row_layout.addWidget(QLabel(cv)) + row_layout.addStretch(1) + del_btn = QPushButton("X") + del_btn.clicked.connect(lambda _c=False, v=cv: self.delete_custom_voice(v)) + row_layout.addWidget(del_btn) + self._list_layout.addWidget(row) + + def delete_custom_voice(self, name: str) -> None: + if QMessageBox.question(self, "Confirm", f"Delete voice '{name}'?") != QMessageBox.StandardButton.Yes: + return + try: + path = os.path.join(CUSTOM_VOICES_DIR, f"{name}.pt") + if os.path.exists(path): + os.remove(path) + self.refresh_voice_lists() + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to delete: {e}") + + def preview_mix(self) -> None: + v1 = self.voice_a_combo.currentText() + v2 = self.voice_b_combo.currentText() + ratio = self._ratio_value() + op = self.op_combo.currentText() + preview_lang = self.preview_lang_combo.currentData() + + preview_text = spec.MIX_PREVIEW_TEXT.get(preview_lang, spec.MIX_PREVIEW_TEXT_DEFAULT) + tmp_voice_name = "_tmp_mix_preview" + tmp_audio_path = os.path.join(tempfile.gettempdir(), "kokoro_mix_preview.wav") + + self.mix_status_label.setText("Generating preview...") + + async def _run_preview(): + success, msg, tensor = await self.app.engine.mix_voices(v1, v2, ratio, tmp_voice_name, op=op) + if not success: + return False, msg + success = await self.app.engine.generate_preview( + preview_text, tmp_voice_name, 1.0, tmp_audio_path, voice_tensor=tensor, lang_code=preview_lang, + ) + try: + p = os.path.join(CUSTOM_VOICES_DIR, f"{tmp_voice_name}.pt") + if os.path.exists(p): + os.remove(p) + except Exception: + pass + return success, "" + + def _done(future): + try: + success, err = future.result() + except Exception as e: + success, err = False, str(e) + self.previewFinished.emit(success, err) + if success: + playback.play(tmp_audio_path) + + future = self.app.engine.worker.run_coro(_run_preview()) + future.add_done_callback(_done) + + def _on_preview_finished(self, success: bool, err: str) -> None: + if success: + self.mix_status_label.setText("Playing preview...") + else: + self.mix_status_label.setText(f"Preview failed: {err}") + + def mix_voice_action(self) -> None: + v1 = self.voice_a_combo.currentText() + v2 = self.voice_b_combo.currentText() + ratio = self._ratio_value() + op = self.op_combo.currentText() + name = self.mix_name_edit.text().strip() + + if not name: + QMessageBox.warning(self, "Error", "Please enter a name for the new voice.") + return + if not re.match(r"^[a-zA-Z0-9_-]+$", name): + QMessageBox.warning(self, "Error", "Invalid name. Use alphanumeric, _, - only.") + return + if name in self.app.get_all_voices(): + if QMessageBox.question(self, "Overwrite", f"Voice '{name}' exists. Overwrite?") != QMessageBox.StandardButton.Yes: + return + + self.mix_status_label.setText("Mixing...") + self.app.set_ui_state(True) + self._pending_name = name + + def _done(future): + try: + success, msg, _tensor = future.result() + except Exception as e: + success, msg = False, str(e) + self.mixFinished.emit(success, msg) + + future = self.app.engine.worker.run_coro(self.app.engine.mix_voices(v1, v2, ratio, name, op=op)) + future.add_done_callback(_done) + + def _on_mix_finished(self, success: bool, msg: str) -> None: + self.app.set_ui_state(False) + if success: + self.mix_status_label.setText(f"Saved: {self._pending_name}") + self.refresh_voice_lists() + else: + self.mix_status_label.setText(f"Error: {msg}") diff --git a/kokoro_gui/qt/docks/settings_dock.py b/kokoro_gui/qt/docks/settings_dock.py new file mode 100644 index 0000000..4b41cc1 --- /dev/null +++ b/kokoro_gui/qt/docks/settings_dock.py @@ -0,0 +1,423 @@ +"""Settings dock: item 2 ("Settings panel rescoping") of the DAW-for-text +redesign's remaining-work roadmap. Renders the schema-driven config fields +(voice/speed/lang_code/split_pattern/format/num_threads/caching, plus any +backend-specific groups) and the hand-built Audio Control widgets +(volume/pitch/FX-preset-combo/apply_fx/normalize/trim) that used to live in +`GenerationDock` - now scoped to whatever `self.app.selection` currently +points at, instead of always editing the whole document's defaults. + +The Output / Processing Options groups that used to sit here moved to the +Export dialog (`kokoro_gui/qt/docks/export_dialog.py`, section 6 of +Claude/PLAN_ui_shell_redesign.md): they describe the export, not the +selection. + +Three states, keyed off `SelectionModel.kind`: + +- "none": values come from `self.app.settings` (today's whole-document + defaults) - the literal migration of what `GenerationDock._build_schema_form` + used to do. `on_change` just schedules an autosave, same as before. +- "clip": values come from `Document.effective_config_for_clip(clip)`; edits + write into `clip.overrides` (never `app.settings`) - dirtying falls out for + free since `dirty.is_clip_dirty` recomputes from `effective_config_for_clip` + fresh every time, no explicit "mark dirty" call needed anywhere here. +- "character": values come from `character.preset_data`; edits write there + directly, which every non-overridden clip using that character picks up + live the next time `effective_config_for_clip` is read. + +Only `ALLOWED_PRESET_KEYS` (kokoro_gui/engine/presets.py) can vary per clip/ +character - that's exactly voice/speed/volume/pitch/split_pattern/normalize/ +trim/format/apply_fx/fx_preset. Every other schema field (lang_code, +num_threads, caching, a backend's own non-preset fields) is rendered +disabled (not hidden) in clip/character mode, via `SchemaFormWidget.widget_for` +- its value there is still sourced from `app.settings`, since that's what + `_assemble_clip_config` actually uses for those keys regardless of which + clip is selected. + +`get_state()` deliberately does NOT reflect whatever mode is currently +rendered: `app.py`'s `_assemble_config`/`_assemble_clip_config` need the +project-wide ("none") defaults unconditionally, no matter what's selected in +this dock's UI at the moment they're called. While "none" is rendered, that's +just this dock's live widgets; while a clip/character is rendered instead, +there's no live "none" widget to read, so the last known "none" values are +cached in `self._none_values` at the moment the dock switches away from +"none" (see `_build_for_selection`). +""" +from __future__ import annotations + +import os + +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDockWidget, QDoubleSpinBox, QFormLayout, + QGroupBox, QHBoxLayout, QScrollArea, QVBoxLayout, QWidget, +) + +import kokoro_gui.qt.app as qt_app_module +from kokoro_gui.engine.presets import ALLOWED_PRESET_KEYS +from kokoro_gui.qt import spec +from kokoro_gui.qt.schema_form import SchemaFormWidget + +# Keys tracked in the internal "none"-state cache/live-widget snapshot that +# are NOT part of get_state()'s public contract (GenerationDock.get_state()'s +# old shape never included these - "apply_fx" has its own apply_fx_enabled() +# accessor, "fx_preset" is only ever used as a display string / preset name). +_INTERNAL_ONLY_KEYS = ("apply_fx", "fx_preset") + + +class SettingsDock(QDockWidget): + def __init__(self, app, parent=None): + super().__init__("Settings", parent) + self.setObjectName("dock_settings") + self.app = app + + self.schema_form: SchemaFormWidget | None = None + self._mode = "none" + self._target = None + # Seeded from app.settings up front (not left as {}) - the + # TranscriptEditor built by GenerationDock (constructed just before + # this dock) can already have selected a clip by the time this dock + # is built, e.g. its initial cursor position lands inside a clip + # loaded from a persisted document - so this dock's very first + # render may start in "clip"/"character" mode, never having passed + # through a live "none" render to snapshot from. + self._none_values: dict = self._project_default_snapshot() + self._constructing_schema_form = False + + content = QWidget() + outer = QVBoxLayout(content) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + inner = QWidget() + layout = QVBoxLayout(inner) + scroll.setWidget(inner) + outer.addWidget(scroll) + + # --- Schema-driven config (moved from GenerationDock) --- + self.schema_group = QGroupBox("Configuration") + self.schema_layout = QVBoxLayout(self.schema_group) + layout.addWidget(self.schema_group) + + # --- Audio control (volume/pitch/FX preset - hand-built, moved + # from GenerationDock) --- + audio_group = QGroupBox("Audio Control") + audio_form = QFormLayout(audio_group) + self.volume_spin = QDoubleSpinBox() + self.volume_spin.setRange(0.1, 2.0) + self.volume_spin.setSingleStep(0.1) + audio_form.addRow("Volume:", self.volume_spin) + + self.pitch_spin = QDoubleSpinBox() + self.pitch_spin.setRange(-12, 12) + self.pitch_spin.setSingleStep(1) + audio_form.addRow("Pitch (st):", self.pitch_spin) + + fx_row = QWidget() + fx_row_layout = QHBoxLayout(fx_row) + fx_row_layout.setContentsMargins(0, 0, 0, 0) + self.fx_preset_combo = QComboBox() + self.apply_fx_check = QCheckBox("Apply") + fx_row_layout.addWidget(self.fx_preset_combo, 1) + fx_row_layout.addWidget(self.apply_fx_check) + audio_form.addRow("FX Preset:", fx_row) + + self.normalize_check = QCheckBox("Normalize") + self.trim_check = QCheckBox("Trim Silence") + toggles_row = QWidget() + toggles_layout = QHBoxLayout(toggles_row) + toggles_layout.setContentsMargins(0, 0, 0, 0) + toggles_layout.addWidget(self.normalize_check) + toggles_layout.addWidget(self.trim_check) + audio_form.addRow("", toggles_row) + layout.addWidget(audio_group) + + layout.addStretch(1) + self.setWidget(content) + + # Hand-built widgets are constructed once and never torn down - only + # their displayed values change on selection change (see + # `_refresh_hand_built_display`); their signals are wired once, here. + self.volume_spin.valueChanged.connect(lambda v: self._on_hand_built_changed("volume", v)) + self.pitch_spin.valueChanged.connect(lambda v: self._on_hand_built_changed("pitch", v)) + self.normalize_check.toggled.connect(lambda v: self._on_hand_built_changed("normalize", v)) + self.trim_check.toggled.connect(lambda v: self._on_hand_built_changed("trim", v)) + self.apply_fx_check.toggled.connect(lambda v: self._on_hand_built_changed("apply_fx", v)) + self.fx_preset_combo.currentTextChanged.connect(self._on_fx_preset_selected) + + self.refresh_fx_presets() + self._build_for_selection() + self.app.selection.changed.connect(self._on_selection_changed) + + # --- selection-driven three-state rendering --------------------------- + + def _resolve_mode(self): + """`(mode, target)` for the current `self.app.selection` - falls + back to `("none", None)` for a stale clip/character id (already + removed) or for the "range"/"none" selection kinds, neither of + which has a clip/character to scope against.""" + kind = self.app.selection.kind + if kind == "clip": + clip = self.app.document.get_clip(self.app.selection.selected_clip_id) + if clip is not None: + return "clip", clip + elif kind == "character": + character = self.app.document.get_character(self.app.selection.selected_character_id) + if character is not None: + return "character", character + return "none", None + + def _on_selection_changed(self) -> None: + self._build_for_selection() + + def _build_for_selection(self) -> None: + # Snapshot the outgoing "none" state's live values before switching + # away from it - there's no live "none" widget to read back from + # once a clip/character is being rendered instead, but get_state() + # must keep returning them regardless. + if self._mode == "none" and self.schema_form is not None: + self._none_values = self._snapshot_none_values() + + self._mode, self._target = self._resolve_mode() + self._build_schema_form() + self._refresh_hand_built_display() + + # --- schema form (rebuilt on selection change AND on engine switch) --- + + def _project_default_snapshot(self) -> dict: + """A valid `_none_values`-shaped dict sourced purely from + `app.settings` - used to seed `self._none_values` at construction, + before this dock has ever necessarily rendered "none" mode live + (see the comment where it's assigned in `__init__`).""" + values = self._base_values_from_settings() + values.update({ + "volume": self.app.settings.get("volume", 1.0), + "pitch": self.app.settings.get("pitch", 0.0), + "normalize": self.app.settings.get("normalize", False), + "trim_silence": self.app.settings.get("trim", False), + "apply_fx": self.app.settings.get("apply_fx", True), + "fx_preset": self.app.settings.get("fx_preset", "Select FX Preset..."), + }) + return values + + def _base_values_from_settings(self) -> dict: + """Project-wide defaults sourced from `app.settings` - exactly what + `GenerationDock._build_schema_form` used to build its `values` dict + from. Also what a disabled (non-ALLOWED_PRESET_KEYS) field shows in + clip/character mode, since those keys are always sourced from + project settings regardless of selection (see + `app.py`'s `_assemble_clip_config`).""" + return { + "lang_code": self.app.settings.get("lang_code", "a"), + "voice": self.app.settings.get("voice", "af_heart"), + "speed": self.app.settings.get("speed", 1.0), + "split_pattern": self.app.settings.get("split_pattern", r"\n+"), + "format": self.app.settings.get("format", "wav"), + "num_threads": self.app.settings.get("num_threads", 1), + "caching": self.app.settings.get("caching", True), + } + + def _current_schema_values(self) -> dict: + values = self._base_values_from_settings() + if self._mode == "clip": + values.update(self.app.document.effective_config_for_clip(self._target)) + elif self._mode == "character": + values.update(self._target.preset_data) + return values + + def _build_schema_form(self) -> None: + if self.schema_form is not None: + self.schema_layout.removeWidget(self.schema_form) + self.schema_form.deleteLater() + + schema = self.app.backend.get_config_schema() + lang_code = self.app.settings.get("lang_code", "a") + voice_choices = [(v, v) for v in self.app.get_all_voices(lang_code)] + values = self._current_schema_values() + # See GenerationDock's former `_build_schema_form` docstring note: + # "voice" is always GUI-resolved; "lang_code" only if the backend's + # own schema leaves it choices=None. + overrides = {"voice": voice_choices} + lang_field = next((f for f in schema if f.key == "lang_code"), None) + if lang_field is not None and lang_field.choices is None: + overrides["lang_code"] = [(label, code) for label, code in spec.LANGUAGES.items()] + + self._constructing_schema_form = True + try: + self.schema_form = SchemaFormWidget( + schema, values, + choices_overrides=overrides, + # "lexicon" has its own dedicated widget (LexiconDock's CRUD + # list); "pitch" has its own dedicated widget too (this + # dock's hand-built pitch_spin, in "Audio Control") - both + # backends that declare a schema "pitch" field (kokoro.py, + # dummy.py) would otherwise render a second, independent + # pitch control that fights the hand-built one for the same + # ALLOWED_PRESET_KEYS override slot in clip/character mode. + skip_keys={"lexicon", "pitch"}, + on_change=self._on_schema_field_changed, + ) + finally: + self._constructing_schema_form = False + self.schema_layout.addWidget(self.schema_form) + + if self._mode != "none": + for f in schema: + if f.key in ALLOWED_PRESET_KEYS: + continue + widget = self.schema_form.widget_for(f.key) + if widget is not None: + widget.setEnabled(False) + + def rebuild_schema_form(self) -> None: + """Called by app.py's switch_engine - re-renders this dock's schema + fields for the newly-active backend, keeping whatever + clip/character/none mode is currently selected.""" + self._build_schema_form() + + def refresh_voice_choices(self) -> None: + lang_code = self.schema_form.values().get("lang_code", "a") + voices = self.app.get_all_voices(lang_code) + current = self.schema_form.values().get("voice") + self.schema_form.set_choices("voice", [(v, v) for v in voices], current) + + def _on_schema_field_changed(self, key: str, value) -> None: + if self._constructing_schema_form: + return + if key == "lang_code": + self.refresh_voice_choices() + if self._mode == "none": + self.app.schedule_save() + return + if key not in ALLOWED_PRESET_KEYS: + return # defense in depth - the field is disabled, unreachable via the UI + if self._target is not None: + if self._mode == "clip": + self._target.overrides[key] = value + elif self._mode == "character": + self._target.preset_data[key] = value + self.app.schedule_save() + self.app.refresh_timeline() + + # --- hand-built widgets (volume/pitch/normalize/trim/apply_fx/fx_preset) -- + + def _refresh_hand_built_display(self) -> None: + if self._mode == "clip": + cfg = self.app.document.effective_config_for_clip(self._target) + elif self._mode == "character": + cfg = self._target.preset_data + else: + cfg = {} + base = { + "volume": self.app.settings.get("volume", 1.0), + "pitch": self.app.settings.get("pitch", 0.0), + "normalize": self.app.settings.get("normalize", False), + "trim": self.app.settings.get("trim", False), + "apply_fx": self.app.settings.get("apply_fx", True), + "fx_preset": self.app.settings.get("fx_preset", "Select FX Preset..."), + } + base.update(cfg) + + widgets = (self.volume_spin, self.pitch_spin, self.normalize_check, + self.trim_check, self.apply_fx_check, self.fx_preset_combo) + for w in widgets: + w.blockSignals(True) + try: + self.volume_spin.setValue(base["volume"]) + self.pitch_spin.setValue(base["pitch"]) + self.normalize_check.setChecked(bool(base["normalize"])) + self.trim_check.setChecked(bool(base["trim"])) + self.apply_fx_check.setChecked(bool(base["apply_fx"])) + fx_name = base["fx_preset"] or "Select FX Preset..." + idx = self.fx_preset_combo.findText(fx_name) + self.fx_preset_combo.setCurrentIndex(idx if idx >= 0 else 0) + finally: + for w in widgets: + w.blockSignals(False) + + def _on_hand_built_changed(self, key: str, value) -> None: + if self._mode == "none": + # Volume/pitch/normalize/trim/apply_fx are read-time + # post-processing for clips: re-render, nothing to regenerate. + self.app.schedule_save() + self.app.refresh_timeline() + return + if key not in ALLOWED_PRESET_KEYS: + return # defense in depth - every hand-built field is in ALLOWED_PRESET_KEYS today + if self._target is not None: + if self._mode == "clip": + self._target.overrides[key] = value + elif self._mode == "character": + self._target.preset_data[key] = value + self.app.schedule_save() + self.app.refresh_timeline() + + def _on_fx_preset_selected(self, name: str) -> None: + if not name or name == "Select FX Preset...": + return + if self._mode == "none": + # "none" mode's FX preset combo actually *loads* the preset's + # resolved values into the global FX dock - project-wide FX + # settings are a single shared instance, not per-clip. + self.app.fx_dock.load_preset(name) + return + # clip/character mode only ever stores the preset *name* - resolved + # into actual FX values later by app.py's _assemble_clip_config. + if self._target is not None: + if self._mode == "clip": + self._target.overrides["fx_preset"] = name + elif self._mode == "character": + self._target.preset_data["fx_preset"] = name + self.app.schedule_save() + self.app.refresh_timeline() + + # --- state (feeds app._assemble_config) --- + + def _snapshot_none_values(self) -> dict: + """Everything about the live "none" state worth caching for later - + a superset of get_state()'s public contract (also carries + apply_fx/fx_preset, used internally by `apply_fx_enabled()` and the + legacy generation-preset combo).""" + state = dict(self.schema_form.values()) + state.update({ + "volume": self.volume_spin.value(), + "pitch": self.pitch_spin.value(), + "normalize": self.normalize_check.isChecked(), + "trim_silence": self.trim_check.isChecked(), + "apply_fx": self.apply_fx_check.isChecked(), + "fx_preset": self.fx_preset_combo.currentText(), + }) + return state + + def get_state(self) -> dict: + """Always the project-wide ("none") state's values, regardless of + what's currently rendered - see this module's docstring.""" + src = self._snapshot_none_values() if self._mode == "none" else self._none_values + return {k: v for k, v in src.items() if k not in _INTERNAL_ONLY_KEYS} + + def apply_fx_enabled(self) -> bool: + if self._mode == "none": + return self.apply_fx_check.isChecked() + return bool(self._none_values.get("apply_fx", True)) + + def set_fx_preset_display(self, name: str) -> None: + """Sets the FX preset combo's displayed text to `name` - used after + `fx_dock.load_preset`/a generation preset names one - without + re-triggering `_on_fx_preset_selected`.""" + if self._mode == "none": + self.fx_preset_combo.blockSignals(True) + self.fx_preset_combo.setCurrentText(name) + self.fx_preset_combo.blockSignals(False) + else: + self._none_values["fx_preset"] = name + + # --- FX preset combo mirror (kept in sync with the FX dock's own combo) -- + + def refresh_fx_presets(self) -> None: + presets = ["Select FX Preset..."] + if os.path.exists(qt_app_module.FX_PRESETS_DIR): + files = [f for f in os.listdir(qt_app_module.FX_PRESETS_DIR) if f.endswith(".json")] + presets.extend(f[:-5] for f in files) + self.fx_preset_combo.blockSignals(True) + self.fx_preset_combo.clear() + self.fx_preset_combo.addItems(presets) + self.fx_preset_combo.setCurrentText("Select FX Preset...") + self.fx_preset_combo.blockSignals(False) diff --git a/kokoro_gui/qt/docks/timeline_dock.py b/kokoro_gui/qt/docks/timeline_dock.py new file mode 100644 index 0000000..1ba15f9 --- /dev/null +++ b/kokoro_gui/qt/docks/timeline_dock.py @@ -0,0 +1,441 @@ +"""Timeline dock: renders `app.document`'s clips/tracks/characters as a +multi-track timeline (Claude/PLAN_daw_ui_ux_redesign.md). Wires +kokoro_gui/qt/timeline_view.py's `TimelineView` into the docked shell - +unconditional, not capability-gated, since it renders Document state, which +is engine-independent. + +Also owns per-clip Generate: a right-click on a clip block runs the +existing, unmodified `process_chunk_task`/`compute_cache_key` machinery +(via `KokoroEngine.generate_clip_audio`, kokoro_gui/engine/conversion.py) +for just that clip's text/config, and populates its `Segment`s with real +audio - the same "a dock owns its own Signals and reaches into +self.app.engine/self.app.document directly" pattern MixingDock's +preview/mix flow already establishes. +""" +from __future__ import annotations + +import threading + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QComboBox, QDialog, QDockWidget, QHBoxLayout, QLabel, QMessageBox, QPlainTextEdit, + QPushButton, QVBoxLayout, +) + +from kokoro_gui.daw.dirty import build_segments_from_results, take_from_results +from kokoro_gui.daw.undo import ( + AssignCharacterCommand, MoveClipBeforeCommand, MoveClipCommand, ReassignTrackCommand, + SetClipTimestampCommand, TextEditCommand, +) +from kokoro_gui.qt.timeline_view import TimelineWidget + + +class TimelineDock(QDockWidget): + clipGenerationFinished = Signal(str, bool, str) + + # Item 3 ("Consolidated action bar + batch dirty-scoped generation") of + # the DAW-for-text remaining-work roadmap. batchGenerationProgress: + # (completed_count, total_count, current_clip_label). batchGenerationFinished: + # (succeeded_count, failed_count, failed_clip_ids) - app.py connects both + # to update the existing status_label/progress_bar rather than routing + # through EngineSignalBridge, whose shape is built around a single + # character-throughput run with no per-clip identity. + batchGenerationProgress = Signal(int, int, str) + batchGenerationFinished = Signal(int, int, list) + + # Internal-only, bare signal: marshals a completed batch's raw outcomes + # from the background engine-worker thread (where future.add_done_callback + # runs its callback) onto the GUI thread, same reasoning as + # clipGenerationFinished/_pending_results below - document mutation and + # schedule_save()/refresh_timeline() must happen on the GUI thread. + _batchGenerationRaw = Signal() + + def __init__(self, app, parent=None): + super().__init__("Timeline", parent) + self.setObjectName("dock_timeline") + self.app = app + + self.timeline_widget = TimelineWidget(selection_model=self.app.selection) + self.timeline_view = self.timeline_widget.view + self.timeline_view.generateClipRequested.connect(self.on_generate_clip_requested) + self.timeline_view.fxPresetRequested.connect(self.on_fx_preset_requested) + self.timeline_view.clipDragReassigned.connect(self.on_clip_drag_reassigned) + self.timeline_view.subRangeTtsRequested.connect(self.on_sub_range_tts_requested) + self.timeline_view.clipMoved.connect(self.on_clip_moved) + self.timeline_view.unpinRequested.connect(self.on_clip_unpin_requested) + self.timeline_view.playClipRequested.connect(self.on_play_clip_requested) + self.setWidget(self.timeline_widget) + if hasattr(self.app, "themeChanged"): + self.app.themeChanged.connect(self.refresh) + + self.clipGenerationFinished.connect(self._on_clip_generation_finished) + self._batchGenerationRaw.connect(self._on_batch_generation_raw) + + # results for a clip id whose generation future hasn't been picked + # up by _on_clip_generation_finished yet - avoids widening + # clipGenerationFinished's argument types just to carry the result + # list across the thread-safe emit/handle boundary. The engine + # reports the key, take and engine version it generated under in + # each result dict, so nothing is predicted before dispatch. + self._pending_results: dict = {} + + # Outcome list for a batch whose future hasn't been picked up by + # _on_batch_generation_raw yet - same reasoning as _pending_results. + self._pending_batch: list | None = None + self._batch_progress_lock = threading.Lock() + self._batch_completed = 0 + + self.refresh() + + def refresh(self) -> None: + arrangement = self.app.build_arrangement() + self.timeline_view.render_document(self.app.document, arrangement, + clip_samples=self.app.rendered_clip_samples) + + # -- seconds-axis drags (UI9) ------------------------------------------------ + + def on_clip_moved(self, clip_id: str, new_start_s: float) -> None: + """Handles `TimelineView.clipMoved`. Pins the clip's timestamp; if + the drop lands at or before the start of the clip that precedes it in + text order, the clip's text moves too (grill Q13) - to just before the + first clip in text order that now starts at or after it.""" + document = self.app.document + clip = document.get_clip(clip_id) + if clip is None: + return + arrangement = self.app.build_arrangement() + order = [p for p in arrangement.placed] + index = next((i for i, p in enumerate(order) if p.clip.id == clip_id), None) + predecessor = order[index - 1] if index is not None and index > 0 else None + + if predecessor is not None and new_start_s <= predecessor.start_s: + before = next((p for p in order if p.clip.id != clip_id and p.start_s >= new_start_s), None) + if before is not None: + document.undo_stack.push(MoveClipBeforeCommand(clip_id, before.clip.id, timestamp=new_start_s)) + self.app.editor.load_text(document.text) + self.app.schedule_save() + self.app.refresh_timeline() + return + document.undo_stack.push(SetClipTimestampCommand(clip_id, new_start_s)) + self.app.schedule_save() + self.app.refresh_timeline() + + def on_play_clip_requested(self, clip_id: str) -> None: + """Context-menu Play: seek the transport to the clip and play, so + the clip is heard with its read-time post-processing.""" + placed = self.app.current_arrangement().by_clip_id().get(clip_id) + if placed is None: + return + self.app.transport.seek(placed.start_s) + self.app.transport.play() + + def on_clip_unpin_requested(self, clip_id: str) -> None: + if self.app.document.get_clip(clip_id) is None: + return + self.app.document.undo_stack.push(SetClipTimestampCommand(clip_id, None)) + self.app.schedule_save() + self.app.refresh_timeline() + + # -- per-clip Generate --------------------------------------------------- + + def on_generate_clip_requested(self, clip_id: str, regenerate: bool = False) -> None: + """`regenerate` is what the gutter button sends for a clip that is + already clean; the engine then bumps the take instead of returning + the present file (grill TB8). The dirty batch path never sets it.""" + if self.app.is_busy(): + QMessageBox.warning(self, "Busy", "Finish or cancel the current job before generating a clip.") + return + + clip = self.app.document.get_clip(clip_id) + if clip is None: + return + + text = self.app.document.clip_text(clip) + config = self.app._assemble_clip_config(clip) + if regenerate: + config["regenerate"] = True + + self.app.set_ui_state(True) + + def _done(future): + try: + results = future.result() + success = bool(results) + error = "" if success else "Generation produced no audio (cancelled or empty text)." + except Exception as e: + results, success, error = [], False, str(e) + + if success: + self._pending_results[clip_id] = results + self.clipGenerationFinished.emit(clip_id, success, error) + + future = self.app.engine.worker.run_coro(self.app.engine.generate_clip_audio((0, text, config))) + future.add_done_callback(_done) + + def _on_clip_generation_finished(self, clip_id: str, success: bool, error: str) -> None: + self.app.set_ui_state(False) + + clip = self.app.document.get_clip(clip_id) + if success and clip is not None: + results = self._pending_results.pop(clip_id) + self._apply_results(clip, results) + self.app.editor.rehighlight() + self.app.schedule_save() + self.app.refresh_timeline() + elif not success: + self._pending_results.pop(clip_id, None) + self.app.set_status(f"Clip generation failed: {error}", "error") + + def _apply_results(self, clip, results: list) -> None: + """Stamps `clip.segments` and `clip.overrides["take"]` from what the + engine reported. A result without a `cache_key` (a hand-built one + in tests) falls back to the key the app would compute now.""" + fallback = None + if any(not r.get("cache_key") for r in results): + key_fn = self.app.document.segment_key_fn + text = self.app.document.clip_text(clip) + if key_fn is not None: + fallback = key_fn(text, clip) + else: + from kokoro_gui.daw.dirty import compute_expected_cache_hash + + fallback = compute_expected_cache_hash(text, self.app._assemble_clip_config(clip)) + clip.segments = build_segments_from_results(fallback, results) + take = take_from_results(results, default=int(clip.overrides.get("take", 0) or 0)) + if take: + clip.overrides["take"] = take + else: + clip.overrides.pop("take", None) + + # -- per-clip FX preset menu (item 5, "Per-clip FX button") -------------- + + def on_fx_preset_requested(self, clip_id: str, preset_name: str) -> None: + """Handles `TimelineView.fxPresetRequested` - an empty `preset_name` + is the "Clear FX" case. UI6: also selects the clip and raises the + Audio FX tab, so the tab shows the override that was just set. The + undoable `SetClipFxCommand` push lives in + `TranscriptDock.apply_fx_preset_to_clip`, shared with the transcript + header's FX combo.""" + if self.app.document.get_clip(clip_id) is None: + return + self.app.selection.select_clip(clip_id) + self.app.transcript_dock.apply_fx_preset_to_clip(clip_id, preset_name) + self.app.raise_fx_tab() + + # -- drag-to-reassign (item 8, "Drag-to-reassign a clip to a different + # track") ------------------------------------------------------------ + + def on_clip_drag_reassigned(self, clip_id: str, target_track_id: str, should_reassign_character: bool) -> None: + """Handles `TimelineView.clipDragReassigned`. `TimelineView` only + ever hands over bare ids and the already-resolved Reassign/Just-Move + choice (Q9) - it never touches `self.app.document` itself, keeping + it app-independent per this file's module docstring - so clip/track + are re-resolved fresh here before pushing the actual undoable + command (`MoveClipCommand` for "just move", `ReassignTrackCommand` + for "reassign", per item 4).""" + clip = self.app.document.get_clip(clip_id) + target_track = self.app.document.get_track(target_track_id) + if clip is None or target_track is None: + return + + if should_reassign_character: + command = ReassignTrackCommand(clip_id, target_track_id, target_track.character_id) + else: + command = MoveClipCommand(clip_id, target_track_id) + + self.app.document.undo_stack.push(command) + if should_reassign_character: + # character_id changed, which changes the transcript's + # highlight color for this clip's run(s) too. + self.app.editor.rehighlight() + self.app.schedule_save() + self.app.refresh_timeline() + + # -- sub-range TTS replacement (item 9, "Sub-range TTS replacement") ----- + # Per Q27: any sub-range of any clip (including imported audio) can be + # carved out and replaced by fresh TTS under ANY character, not + # necessarily the clip's own. `assign_character_to_range` is already the + # split-or-create primitive for this (tested by + # tests/daw/test_assign_character.py) - the only new work here is the + # dialog and correctly sequencing a real text edit (if the user edits + # the sub-range's transcript) before the character assignment. + + def _build_sub_range_dialog(self, clip, original_text: str) -> QDialog: + """Split out from `on_sub_range_tts_requested` so tests can build and + inspect/drive the dialog without ever calling the blocking `.exec()` + themselves - same precedent as `TimelineView._build_context_menu`/ + `_build_fx_menu`. Widgets are found back via `QDialog.findChild` by + type (there's exactly one `QPlainTextEdit` and one `QComboBox` in + this dialog), the same way a monkeypatched `.exec()` can reach in and + supply canned "user typed X and picked character Y" input.""" + dialog = QDialog(self) + dialog.setWindowTitle("Replace with TTS") + layout = QVBoxLayout(dialog) + + layout.addWidget(QLabel("Text:")) + text_edit = QPlainTextEdit(original_text) + layout.addWidget(text_edit) + + layout.addWidget(QLabel("Character:")) + character_combo = QComboBox() + default_index = 0 + for index, character in enumerate(self.app.document.characters): + character_combo.addItem(character.name, character.id) + if character.id == clip.character_id: + default_index = index + if character_combo.count(): + character_combo.setCurrentIndex(default_index) + layout.addWidget(character_combo) + + button_row = QHBoxLayout() + ok_btn = QPushButton("OK") + ok_btn.clicked.connect(dialog.accept) + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(dialog.reject) + button_row.addWidget(ok_btn) + button_row.addWidget(cancel_btn) + layout.addLayout(button_row) + + return dialog + + def on_sub_range_tts_requested(self, clip_id: str, sub_start: int, sub_end: int) -> None: + """Handles `TimelineView.subRangeTtsRequested`. `TimelineView` only + ever hands over the bare clip id and document-text offsets - it + never touches `self.app.document` itself (same app-independence + pattern every other signal on that widget already establishes), so + the clip is re-resolved here before showing the dialog. + + On OK: if the dialog's (possibly edited) text differs from the + original sub-range text, a `TextEditCommand` for that exact + replacement is pushed FIRST - the new sub-range's end offset is then + recomputed from the edited text's actual length, not the original + `sub_end` (text length may have changed). Then an + `AssignCharacterCommand` carves out `[sub_start, sub_end)` under + whichever character was chosen (Q27: any character, not necessarily + the parent clip's) - `assign_character_to_range`'s existing split + logic already produces correct leftover fragments for the parent + clip's remainder, retaining its `source`/`original_audio_path` + unmodified. Cancel pushes nothing. + """ + clip = self.app.document.get_clip(clip_id) + if clip is None: + return + + document = self.app.document + original_text = document.text[sub_start:sub_end] + dialog = self._build_sub_range_dialog(clip, original_text) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + + text_edit = dialog.findChild(QPlainTextEdit) + character_combo = dialog.findChild(QComboBox) + new_text = text_edit.toPlainText() + character_id = character_combo.currentData() + + if new_text != original_text: + old_text = document.text + new_full_text = old_text[:sub_start] + new_text + old_text[sub_end:] + document.undo_stack.push(TextEditCommand( + position=sub_start, + chars_removed=sub_end - sub_start, + chars_added=len(new_text), + new_text=new_full_text, + )) + sub_end = sub_start + len(new_text) + + if sub_end > sub_start: + document.undo_stack.push(AssignCharacterCommand(sub_start, sub_end, character_id)) + + self.app.schedule_save() + self.app.editor.load_text(document.text) + self.app.refresh_timeline() + self.generate_dirty_clips_requested() + + # -- batch dirty-scoped Generate (item 3) -------------------------------- + + def generate_dirty_clips_requested(self) -> None: + """Dispatches `KokoroEngine.generate_dirty_clips` for every clip + `Document.dirty_clips()` currently reports as stale. Guarded by the + same one-job-at-a-time check `on_generate_clip_requested` already + uses. Callers (`QtTTSApp.on_generate_clicked`) are expected to have + already checked `dirty_clips()` themselves for the "nothing to do" + message - this method silently no-ops on an empty dirty list so it + stays safe to call directly too.""" + if self.app.is_busy(): + QMessageBox.warning(self, "Busy", "Finish or cancel the current job before generating.") + return + + dirty = self.app.document.dirty_clips() + if not dirty: + return + + clips_with_configs = [] + for clip in dirty: + text = self.app.document.clip_text(clip) + config = self.app._assemble_clip_config(clip) + clips_with_configs.append((clip.id, text, config)) + + total = len(clips_with_configs) + self._batch_completed = 0 + self.app.set_ui_state(True) + self.batchGenerationProgress.emit(0, total, "") + + def _on_clip_progress(clip_id, _success): + with self._batch_progress_lock: + self._batch_completed += 1 + completed = self._batch_completed + self.batchGenerationProgress.emit(completed, total, clip_id) + + def _done(future): + try: + outcomes = future.result() + except Exception as e: + # An exception here means the batch never even ran a single + # clip (e.g. the coroutine itself failed to schedule) - + # generate_dirty_clips already catches every per-clip + # exception internally via return_exceptions=True, so this + # branch is the "total failure" case, not a per-clip one. + outcomes = [ + {"clip_id": cid, "success": False, "results": [], "error": str(e), "cancelled": False} + for cid, _text, _cfg in clips_with_configs + ] + + self._pending_batch = outcomes + self._batchGenerationRaw.emit() + + future = self.app.engine.worker.run_coro( + self.app.engine.generate_dirty_clips(clips_with_configs, progress_callback=_on_clip_progress) + ) + future.add_done_callback(_done) + + def _on_batch_generation_raw(self) -> None: + self.app.set_ui_state(False) + + pending = self._pending_batch + self._pending_batch = None + if pending is None: + return + + succeeded_ids = [] + failed_ids = [] + any_segments_updated = False + + for outcome in pending: + clip_id = outcome["clip_id"] + clip = self.app.document.get_clip(clip_id) + if outcome["success"] and clip is not None: + self._apply_results(clip, outcome["results"]) + succeeded_ids.append(clip_id) + any_segments_updated = True + else: + # Failed and cancelled clips alike: leave .segments + # untouched (still whatever they were before this batch - + # possibly empty/dirty, possibly stale-but-present). + failed_ids.append(clip_id) + + if any_segments_updated: + self.app.editor.rehighlight() + self.app.schedule_save() + self.app.refresh_timeline() + + self.batchGenerationFinished.emit(len(succeeded_ids), len(failed_ids), failed_ids) diff --git a/kokoro_gui/qt/docks/transcript_dock.py b/kokoro_gui/qt/docks/transcript_dock.py new file mode 100644 index 0000000..acf3375 --- /dev/null +++ b/kokoro_gui/qt/docks/transcript_dock.py @@ -0,0 +1,177 @@ +"""Transcript dock: the panel a user stares at most (top-left of the 2x2 +grid, Claude/PLAN_ui_shell_redesign.md section 2). Replaces the old +`GenerationDock` ("Generate Audio"), whose Input Source tabs, file-path row, +legacy preset row and Auto-Split row all moved out: Load File is File > +Import Text, the legacy `presets/*.json` combo is gone (Characters replaced +it), auto-split is an option on the Transport dock's Generate menu. + +What's left is a header row with two combos above the editor: + +- Character: reflects the run under the caret (or the selection's first + run); changing it assigns that character to the selection, or to the + caret's whole clip when nothing is selected, or to the caret's line for + untagged text. "Manage characters..." at the bottom opens the Edit > + Characters dialog. +- FX: lists `presets/fx/*.json` plus "(none)" and "Edit in FX tab...". + Changing it sets the caret clip's `fx_override` (resolved values) and + records the preset name in `clip.overrides["fx_preset"]` through + `SetClipFxCommand`, so the choice is undoable and the gutter can name it. +""" +from __future__ import annotations + +from PySide6.QtWidgets import QComboBox, QDockWidget, QHBoxLayout, QLabel, QVBoxLayout, QWidget + +from kokoro_gui.daw.undo import SetClipFxCommand +from kokoro_gui.engine.presets import ALLOWED_FX_PRESET_KEYS, filter_allowed_keys +from kokoro_gui.qt.fx_presets import list_fx_preset_names +from kokoro_gui.qt.transcript_editor import TranscriptEditor, clip_fx_name + +FX_NONE_LABEL = "(none)" +FX_EDIT_LABEL = "Edit in FX tab..." +CHARACTER_MANAGE_LABEL = "Manage characters..." +_MIXED_LABEL = "(mixed)" + + +class TranscriptDock(QDockWidget): + def __init__(self, app, parent=None): + super().__init__("Transcript", parent) + self.setObjectName("dock_transcript") + self.app = app + self._syncing = False + + content = QWidget() + layout = QVBoxLayout(content) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(4) + + header = QHBoxLayout() + header.addWidget(QLabel("Character:")) + self.character_combo = QComboBox() + self.character_combo.setMinimumWidth(120) + header.addWidget(self.character_combo, 1) + header.addSpacing(8) + header.addWidget(QLabel("FX:")) + self.fx_combo = QComboBox() + self.fx_combo.setMinimumWidth(120) + header.addWidget(self.fx_combo, 1) + header.addStretch(1) + layout.addLayout(header) + + self.editor = TranscriptEditor(self.app) + layout.addWidget(self.editor, 1) + self.setWidget(content) + + self.refresh_character_choices() + self.refresh_fx_choices() + self.character_combo.activated.connect(self._on_character_activated) + self.fx_combo.activated.connect(self._on_fx_activated) + self.editor.cursorPositionChanged.connect(self.sync_header) + self.app.selection.changed.connect(self.sync_header) + self.sync_header() + + # -- combo contents ---------------------------------------------------- + + def refresh_character_choices(self) -> None: + self._syncing = True + try: + self.character_combo.clear() + self.character_combo.addItem(_MIXED_LABEL, None) + for character in self.app.document.characters: + self.character_combo.addItem(character.name, character.id) + self.character_combo.insertSeparator(self.character_combo.count()) + self.character_combo.addItem(CHARACTER_MANAGE_LABEL, "__manage__") + finally: + self._syncing = False + self.sync_header() + + def refresh_fx_choices(self) -> None: + self._syncing = True + try: + self.fx_combo.clear() + self.fx_combo.addItem(FX_NONE_LABEL, "") + for name in list_fx_preset_names(self.app.project_dir): + self.fx_combo.addItem(name, name) + self.fx_combo.insertSeparator(self.fx_combo.count()) + self.fx_combo.addItem(FX_EDIT_LABEL, "__edit__") + finally: + self._syncing = False + self.sync_header() + + # -- header <- caret --------------------------------------------------- + + def sync_header(self) -> None: + """Reflect the caret's clip in both combos without firing their + change handlers.""" + if self._syncing: + return + clip = self.editor.current_clip() + self._syncing = True + try: + character_id = clip.character_id if clip is not None else None + index = self.character_combo.findData(character_id) if character_id else 0 + self.character_combo.setCurrentIndex(index if index >= 0 else 0) + + fx_name = clip_fx_name(self.app.document, clip) if clip is not None else None + self.fx_combo.setEnabled(clip is not None) + if not fx_name or fx_name == "custom": + fx_index = 0 if not fx_name else -1 + if fx_name == "custom": + # Resolved values with no recorded name: show as none + # (the gutter says "FX: custom"). + fx_index = 0 + else: + fx_index = self.fx_combo.findData(fx_name) + self.fx_combo.setCurrentIndex(fx_index if fx_index >= 0 else 0) + finally: + self._syncing = False + + # -- header -> document ------------------------------------------------ + + def _on_character_activated(self, index: int) -> None: + if self._syncing: + return + data = self.character_combo.itemData(index) + if data == "__manage__": + self.sync_header() + self.app.open_characters_dialog() + return + if not data: + return + target = self.editor.current_target_range() + if target is None or target[1] <= target[0]: + return + start, end = target + self.editor._push_assign_character(start, end, data) + self.sync_header() + + def _on_fx_activated(self, index: int) -> None: + if self._syncing: + return + data = self.fx_combo.itemData(index) + if data == "__edit__": + self.sync_header() + self.app.raise_fx_tab() + return + clip = self.editor.current_clip() + if clip is None: + return + self.apply_fx_preset_to_clip(clip.id, data or "") + + def apply_fx_preset_to_clip(self, clip_id: str, preset_name: str) -> None: + """Shared with the timeline's FX menu (`TimelineDock.on_fx_preset_requested` + delegates here). Empty `preset_name` clears the override.""" + clip = self.app.document.get_clip(clip_id) + if clip is None: + return + if not preset_name: + fx_values = None + else: + preset = self.app.engine.load_fx_preset(preset_name, self.app.project_dir) + fx_values = filter_allowed_keys(preset, ALLOWED_FX_PRESET_KEYS) if preset else None + self.app.document.undo_stack.push(SetClipFxCommand(clip_id, fx_values, preset_name=preset_name or None)) + self.editor.rehighlight() + self.app.schedule_save() + self.app.refresh_timeline() + self.sync_header() + if self.app.fx_dock is not None: + self.app.fx_dock.refresh_for_selection() diff --git a/kokoro_gui/qt/docks/transport_dock.py b/kokoro_gui/qt/docks/transport_dock.py new file mode 100644 index 0000000..2c78ce8 --- /dev/null +++ b/kokoro_gui/qt/docks/transport_dock.py @@ -0,0 +1,217 @@ +"""Transport / Generate dock (bottom-right of the drawing's 2x2 grid, see +Claude/PLAN_ui_shell_redesign.md section 1). + +What `QtTTSApp._build_action_bar` used to build as the central widget, +moved into a dock and reshaped into three rows: + +1. play / pause / stop (round `QToolButton`s with `kokoro_gui.qt.icons` + glyphs, retinted on `themeChanged`), elapsed / total time, loop toggle - + driven by `kokoro_gui.audio.transport.Transport` through the app. +2. Preview, Generate (the row's one `primary` button: a `QToolButton` + whose menu holds "Generate dirty clips", "Auto-split then generate" and + the checkable "Split by paragraph"), Cancel (flat). +3. One progress bar carrying the status/detail text via `setFormat`, in + place of the three separate labels the old central widget had. + +`set_status`/`set_progress`/`set_busy` are the app's only entry points for +feedback; `is_busy()` is the one-job-at-a-time guard every generation +trigger checks (it used to be `app.cancel_btn.isEnabled()`). +""" +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QAction +from PySide6.QtWidgets import ( + QDockWidget, QHBoxLayout, QLabel, QMenu, QProgressBar, QPushButton, QToolButton, QVBoxLayout, QWidget, +) + +from kokoro_gui.engine.time_utils import format_duration +from kokoro_gui.qt import icons, theme + + +def format_clock(seconds: float) -> str: + """mm:ss.t for the transport readout (a tenth is enough for a playhead + readout; `format_duration` is for the ETA/elapsed line).""" + seconds = max(0.0, float(seconds)) + minutes = int(seconds // 60) + rest = seconds - minutes * 60 + return f"{minutes:02d}:{rest:04.1f}" + + +class TransportDock(QDockWidget): + playRequested = Signal() + pauseRequested = Signal() + stopRequested = Signal() + loopToggled = Signal(bool) + + def __init__(self, app, parent=None): + super().__init__("Transport", parent) + self.setObjectName("dock_transport") + self.app = app + self._status_text = "Ready" + self._detail_text = "" + self._busy = False + + content = QWidget() + layout = QVBoxLayout(content) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(6) + + # Row 1: transport + row1 = QHBoxLayout() + row1.setSpacing(4) + # Painted glyphs rather than Fusion's SP_Media* pixmaps or the + # U+23F5-family characters: the pixmaps are the 2000s look this + # dock is trying to shed, the characters depend on installed fonts + # (and are blank under the offscreen platform). + self.play_btn = QToolButton() + self.play_btn.setToolTip("Play (Space)") + self.pause_btn = QToolButton() + self.pause_btn.setToolTip("Pause (Space)") + self.pause_btn.setEnabled(False) + self.stop_btn = QToolButton() + self.stop_btn.setToolTip("Stop") + for btn in (self.play_btn, self.pause_btn, self.stop_btn): + btn.setProperty("transport", True) + btn.setAutoRaise(True) + row1.addWidget(btn) + self._apply_icons() + self.app.themeChanged.connect(self._apply_icons) + self.time_label = QLabel("00:00.0 / 00:00.0") + self.time_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + row1.addSpacing(8) + row1.addWidget(self.time_label) + row1.addStretch(1) + self.loop_btn = QPushButton("Loop") + self.loop_btn.setCheckable(True) + row1.addWidget(self.loop_btn) + layout.addLayout(row1) + + self.play_btn.clicked.connect(self.playRequested) + self.pause_btn.clicked.connect(self.pauseRequested) + self.stop_btn.clicked.connect(self.stopRequested) + self.loop_btn.toggled.connect(self.loopToggled) + + # Row 2: generate + row2 = QHBoxLayout() + self.preview_btn = QPushButton("Preview") + self.preview_btn.clicked.connect(self.app.preview_conversion) + row2.addWidget(self.preview_btn) + + self.generate_btn = QToolButton() + self.generate_btn.setText("Generate") + self.generate_btn.setProperty("primary", True) + self.generate_btn.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) + self.generate_btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly) + self.generate_btn.clicked.connect(self.app.on_generate_clicked) + self.generate_menu = QMenu(self.generate_btn) + self.generate_dirty_action = QAction("Generate dirty clips", self) + self.generate_dirty_action.triggered.connect(self.app.on_generate_clicked) + self.auto_split_action = QAction("Auto-split then generate", self) + self.auto_split_action.triggered.connect(self.app.auto_split_and_generate) + self.split_paragraph_action = QAction("Split by paragraph", self) + self.split_paragraph_action.setCheckable(True) + self.split_paragraph_action.setChecked(bool(self.app.settings.get("auto_split_by_paragraph", False))) + self.split_paragraph_action.toggled.connect(self._on_split_paragraph_toggled) + self.generate_menu.addAction(self.generate_dirty_action) + self.generate_menu.addAction(self.auto_split_action) + self.generate_menu.addSeparator() + self.generate_menu.addAction(self.split_paragraph_action) + self.generate_btn.setMenu(self.generate_menu) + row2.addWidget(self.generate_btn) + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.setFlat(True) + self.cancel_btn.clicked.connect(self.app.cancel_conversion) + self.cancel_btn.setEnabled(False) + row2.addWidget(self.cancel_btn) + row2.addStretch(1) + layout.addLayout(row2) + + # Row 3: progress with the status inside it + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + self.progress_bar.setTextVisible(True) + self.progress_bar.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.progress_bar) + + layout.addStretch(1) + self.setWidget(content) + self._refresh_format() + + def _apply_icons(self) -> None: + pal = theme.current() + self.play_btn.setIcon(icons.icon("play", pal.accent)) + self.pause_btn.setIcon(icons.icon("pause", pal.text)) + self.stop_btn.setIcon(icons.icon("stop", pal.text)) + + # -- status / progress ------------------------------------------------- + + def _refresh_format(self) -> None: + parts = [self._status_text] + if self._detail_text: + parts.append(self._detail_text) + text = " | ".join(p for p in parts if p) + if self._busy: + text = f"%p% {text}" + self.progress_bar.setFormat(text) + + def status_text(self) -> str: + return self._status_text + + def detail_text(self) -> str: + return self._detail_text + + def set_status(self, message: str, kind: str = "info") -> None: + """`kind` is "info" | "error" | "warning" | "success" | "busy" and + only changes the text color.""" + self._status_text = (message or "").split("\n")[0] + colors = {"error": "#ff5555", "warning": "orange", "success": "#2e8b57", "busy": "#1a73e8"} + color = colors.get(kind) + self.progress_bar.setStyleSheet(f"QProgressBar {{ color: {color}; }}" if color else "") + self._refresh_format() + + def set_progress(self, percent: float, detail: str = "", elapsed: float | None = None, + eta: str | None = None) -> None: + self.progress_bar.setValue(int(max(0, min(100, percent)))) + pieces = [] + if detail: + pieces.append(detail) + if elapsed is not None: + eta_text = eta if eta else "--:--" + pieces.append(f"{format_duration(elapsed)} / ETA {eta_text}") + self._detail_text = " ".join(pieces) + self._refresh_format() + + def set_progress_value(self, percent: float) -> None: + self.progress_bar.setValue(int(max(0, min(100, percent)))) + + def set_busy(self, busy: bool) -> None: + self._busy = busy + self.generate_btn.setEnabled(not busy) + self.preview_btn.setEnabled(not busy) + self.cancel_btn.setEnabled(busy) + if not busy: + self._detail_text = "" + self._refresh_format() + + def is_busy(self) -> bool: + return self._busy + + # -- transport readout ------------------------------------------------- + + def set_position(self, position_s: float, total_s: float) -> None: + self.time_label.setText(f"{format_clock(position_s)} / {format_clock(total_s)}") + + def set_playing(self, playing: bool) -> None: + self.play_btn.setEnabled(not playing) + self.pause_btn.setEnabled(playing) + + # -- generate menu ----------------------------------------------------- + + def _on_split_paragraph_toggled(self, checked: bool) -> None: + self.app.settings["auto_split_by_paragraph"] = checked + self.app.schedule_save() + if self.app.transcript_dock is not None: + self.app.transcript_dock.editor.refresh_split_rules() diff --git a/kokoro_gui/qt/docks/voice_clone_dock.py b/kokoro_gui/qt/docks/voice_clone_dock.py new file mode 100644 index 0000000..81a5f75 --- /dev/null +++ b/kokoro_gui/qt/docks/voice_clone_dock.py @@ -0,0 +1,302 @@ +"""Voice Reference dock: browse a reference WAV, get (and edit) an +auto-transcript of it via kokoro_gui/engine/asr.py, and save it under a name +so it shows up as a selectable "voice" for any backend whose +`capabilities.supports_voice_cloning` is true (today: Audio8BackendAdapter - +kokoro_gui/engines/audio8_tts.py). Shown only for such a backend - see +app.py's `_sync_voice_clone_dock`, the same show/hide-on-engine-switch +pattern `_sync_mixing_dock` uses for the Mixing dock. + +The auto-transcribe step itself can run on either of `kokoro_gui.engine.asr`'s +two registered engines (`ASR_ENGINES`) - the default "Audio8-ASR-0.1B" +(online, higher quality) or "Vosk" (fully offline, needs a model folder +downloaded by hand). The engine choice is persisted in `app.settings` +(`asr_engine`, pulled via `get_state()` the same way `FXDock`/ +`GenerationDock` persist their own widget state) - but Vosk's model folder +is *not*: it lives in the `VOSK_MODEL_PATH` environment variable, normally +via a `.env` file at the project root, rather than in `config_qt.json`, +since it's a one-time deployment detail rather than a per-session GUI +preference like every other setting this dock/`FXDock`/`GenerationDock` +persist. It's still editable from here though - Browse or type a path and +click Save to write it into `.env` (`kokoro_gui.engine.asr.set_vosk_model_path`), +or Reload to discard an unsaved edit and re-read whatever's actually in +`.env` right now (picks up a change made by hand while the app was already +running). + +Saving is required before a reference can be used for generation - there is +no "generate with an unsaved wav" path, deliberately: the Generation dock's +Voice dropdown is the single source of truth for which reference gets used +(populated from `Audio8ReferenceStore.list_references()` via +`app.backend.get_voices()`), so there is never a question of whether a +freshly-browsed-but-unsaved wav or the dropdown's selection "wins". +""" +from __future__ import annotations + +import asyncio +import os + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QComboBox, QDockWidget, QFileDialog, QFrame, QHBoxLayout, QLabel, QLineEdit, + QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QVBoxLayout, QWidget, +) + +from kokoro_gui.engine.asr import ( + ASR_ENGINES, get_vosk_model_path, reload_vosk_model_path, set_vosk_model_path, transcribe_wav, +) +from kokoro_gui.engines import audio8_tts +from kokoro_gui.engines.audio8_tts import Audio8ReferenceStore + + +class VoiceCloneDock(QDockWidget): + transcribeFinished = Signal(bool, str) + saveFinished = Signal(bool, str) + + def __init__(self, app, parent=None): + super().__init__("Voice Reference", parent) + self.setObjectName("dock_voice_clone") + self.app = app + self.transcribeFinished.connect(self._on_transcribe_finished) + self.saveFinished.connect(self._on_save_finished) + + content = QWidget() + layout = QVBoxLayout(content) + + layout.addWidget(QLabel("Reference Audio")) + wav_row = QHBoxLayout() + self.wav_path_edit = QLineEdit() + wav_row.addWidget(self.wav_path_edit, 1) + browse_btn = QPushButton("Browse...") + browse_btn.clicked.connect(self._browse_wav) + wav_row.addWidget(browse_btn) + layout.addLayout(wav_row) + + layout.addWidget(QLabel("Transcript (what's said in the audio)")) + self.transcript_edit = QPlainTextEdit() + self.transcript_edit.setFixedHeight(100) + layout.addWidget(self.transcript_edit) + + engine_row = QHBoxLayout() + engine_row.addWidget(QLabel("ASR Engine:")) + self.asr_engine_combo = QComboBox() + for info in ASR_ENGINES: + self.asr_engine_combo.addItem(info.display_name, info.id) + self.asr_engine_combo.setToolTip("\n\n".join(f"{i.display_name}: {i.description}" for i in ASR_ENGINES)) + saved_engine_idx = self.asr_engine_combo.findData(self.app.settings.get("asr_engine", ASR_ENGINES[0].id)) + self.asr_engine_combo.setCurrentIndex(saved_engine_idx if saved_engine_idx >= 0 else 0) + self.asr_engine_combo.currentIndexChanged.connect(self._on_asr_engine_changed) + engine_row.addWidget(self.asr_engine_combo, 1) + layout.addLayout(engine_row) + + self.vosk_row = QWidget() + vosk_row_layout = QHBoxLayout(self.vosk_row) + vosk_row_layout.setContentsMargins(0, 0, 0, 0) + vosk_row_layout.addWidget(QLabel("Vosk Model:")) + self.vosk_model_edit = QLineEdit() + self.vosk_model_edit.setToolTip( + "Folder of an unzipped model from https://alphacephei.com/vosk/models.\n" + "Save writes this to VOSK_MODEL_PATH in a .env file at the project root." + ) + vosk_row_layout.addWidget(self.vosk_model_edit, 1) + vosk_browse_btn = QPushButton("Browse...") + vosk_browse_btn.clicked.connect(self._browse_vosk_model) + vosk_row_layout.addWidget(vosk_browse_btn) + vosk_save_btn = QPushButton("Save") + vosk_save_btn.setToolTip("Write this path to VOSK_MODEL_PATH in .env.") + vosk_save_btn.clicked.connect(self._save_vosk_model_path) + vosk_row_layout.addWidget(vosk_save_btn) + vosk_reload_btn = QPushButton("Reload") + vosk_reload_btn.setToolTip("Discard unsaved edits and re-read VOSK_MODEL_PATH from .env.") + vosk_reload_btn.clicked.connect(self._reload_vosk_model_path) + vosk_row_layout.addWidget(vosk_reload_btn) + layout.addWidget(self.vosk_row) + + self._sync_vosk_model_edit() + self.vosk_row.setVisible(self.asr_engine_combo.currentData() == "vosk") + + self.transcribe_btn = QPushButton("\U0001F3A4 Auto-Transcribe") + self.transcribe_btn.clicked.connect(self._on_transcribe_clicked) + layout.addWidget(self.transcribe_btn) + + self.status_label = QLabel("") + layout.addWidget(self.status_label) + + save_row = QHBoxLayout() + save_row.addWidget(QLabel("Save As:")) + self.name_edit = QLineEdit() + save_row.addWidget(self.name_edit, 1) + save_btn = QPushButton("Save Reference") + save_btn.clicked.connect(self._on_save_clicked) + save_row.addWidget(save_btn) + layout.addLayout(save_row) + + layout.addWidget(QLabel("Saved References:")) + self.list_scroll = QScrollArea() + self.list_scroll.setWidgetResizable(True) + self.list_scroll.setFixedHeight(180) + self._list_container = QWidget() + self._list_layout = QVBoxLayout(self._list_container) + self.list_scroll.setWidget(self._list_container) + layout.addWidget(self.list_scroll) + + layout.addStretch(1) + self.setWidget(content) + self.refresh_list() + + # --- reference audio / transcript ----------------------------------- + + def _browse_wav(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "Select reference audio", filter="Audio (*.wav)") + if path: + self.wav_path_edit.setText(path) + + # --- ASR engine picker ------------------------------------------------- + + def _on_asr_engine_changed(self, _index: int) -> None: + self.vosk_row.setVisible(self.asr_engine_combo.currentData() == "vosk") + self.app.schedule_save() + + def _sync_vosk_model_edit(self) -> None: + """Fills the Vosk model field from whatever's currently in + `VOSK_MODEL_PATH` - used at dock construction and by Reload, both of + which mean "discard any unsaved edit and show what's really there".""" + self.vosk_model_edit.setText(get_vosk_model_path()) + + def _browse_vosk_model(self) -> None: + path = QFileDialog.getExistingDirectory(self, "Select Vosk model folder") + if path: + self.vosk_model_edit.setText(path) + self._save_vosk_model_path() + + def _save_vosk_model_path(self) -> None: + set_vosk_model_path(self.vosk_model_edit.text()) + self.status_label.setText("Saved VOSK_MODEL_PATH to .env.") + + def _reload_vosk_model_path(self) -> None: + reload_vosk_model_path() + self._sync_vosk_model_edit() + + def get_state(self) -> dict: + """Pulled into `app.settings` by `QtTTSApp.save_settings` so the + chosen ASR engine survives a restart, mirroring how + `FXDock.get_state()`/`GenerationDock.get_state()` are pulled. The + Vosk model path isn't part of this - see this module's docstring.""" + return {"asr_engine": self.asr_engine_combo.currentData() or ASR_ENGINES[0].id} + + def _on_transcribe_clicked(self) -> None: + wav_path = self.wav_path_edit.text().strip() + if not wav_path or not os.path.exists(wav_path): + QMessageBox.warning(self, "Error", "Select a reference audio file first.") + return + + engine = self.asr_engine_combo.currentData() or ASR_ENGINES[0].id + vosk_model_path = self.vosk_model_edit.text().strip() + if engine == "vosk" and not vosk_model_path: + QMessageBox.warning(self, "Error", "Enter a Vosk model folder first.") + return + + self.transcribe_btn.setEnabled(False) + self.status_label.setText("Transcribing...") + + def _done(future): + try: + text = future.result() + self.transcribeFinished.emit(True, text) + except Exception as e: + self.transcribeFinished.emit(False, str(e)) + + # Uses whatever's currently typed in the Vosk model field, whether or + # not it's been Saved yet - transcribing shouldn't require a save + # first, only persisting the path for next run/the standalone CLI does. + future = self.app.engine.worker.run_coro( + asyncio.to_thread(transcribe_wav, wav_path, engine=engine, model_path=vosk_model_path or None) + ) + future.add_done_callback(_done) + + def _on_transcribe_finished(self, success: bool, payload: str) -> None: + self.transcribe_btn.setEnabled(True) + if success: + self.transcript_edit.setPlainText(payload) + self.status_label.setText("Transcribed - review/edit before saving.") + else: + self.status_label.setText(f"Transcription failed: {payload}") + + # --- saved references (name -> wav+transcript sidecar pair) --------- + + def _on_save_clicked(self) -> None: + name = self.name_edit.text().strip() + wav_path = self.wav_path_edit.text().strip() + transcript = self.transcript_edit.toPlainText().strip() + + if not name: + QMessageBox.warning(self, "Error", "Enter a name for this voice reference.") + return + if not wav_path or not os.path.exists(wav_path): + QMessageBox.warning(self, "Error", "Select a reference audio file first.") + return + if not transcript: + QMessageBox.warning(self, "Error", "Enter or auto-transcribe a transcript first.") + return + if name in Audio8ReferenceStore.list_references(): + if QMessageBox.question(self, "Overwrite", f"Reference '{name}' exists. Overwrite?") != QMessageBox.StandardButton.Yes: + return + + try: + Audio8ReferenceStore.save_reference(name, wav_path, transcript) + self.saveFinished.emit(True, name) + except Exception as e: + self.saveFinished.emit(False, str(e)) + + def _on_save_finished(self, success: bool, payload: str) -> None: + if success: + self.status_label.setText(f"Saved: {payload}") + self.refresh_list() + else: + self.status_label.setText(f"Save failed: {payload}") + + def _load_reference(self, name: str) -> None: + """Loads a saved reference back into the editable fields above, for + review/edit/re-save (the user's "edit after if needed" path).""" + self.name_edit.setText(name) + project_dir = getattr(self.app, "project_dir", None) + wav = Audio8ReferenceStore.find_wav(name, project_dir) or os.path.abspath( + os.path.join(audio8_tts.AUDIO8_REFS_DIR, f"{name}.wav")) + self.wav_path_edit.setText(wav) + self.transcript_edit.setPlainText(Audio8ReferenceStore.get_transcript(name, project_dir)) + + def refresh_list(self) -> None: + if hasattr(self.app, "settings_dock") and self.app.settings_dock is not None: + self.app.settings_dock.refresh_voice_choices() + + while self._list_layout.count(): + item = self._list_layout.takeAt(0) + w = item.widget() + if w: + w.deleteLater() + + # Project-local references (a .tbaw's engines/audio8/refs/) show + # alongside the global store; a name in both is the project's. + names = Audio8ReferenceStore.list_references(getattr(self.app, "project_dir", None)) + if not names: + self._list_layout.addWidget(QLabel("No saved voice references yet.")) + return + + for name in names: + row = QFrame() + row_layout = QHBoxLayout(row) + load_btn = QPushButton(name) + load_btn.setFlat(True) + load_btn.clicked.connect(lambda _c=False, n=name: self._load_reference(n)) + row_layout.addWidget(load_btn, 1) + del_btn = QPushButton("✕") + del_btn.clicked.connect(lambda _c=False, n=name: self.delete_reference(n)) + row_layout.addWidget(del_btn) + self._list_layout.addWidget(row) + + def delete_reference(self, name: str) -> None: + if QMessageBox.question(self, "Confirm", f"Delete voice reference '{name}'?") != QMessageBox.StandardButton.Yes: + return + try: + Audio8ReferenceStore.delete_reference(name) + self.refresh_list() + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to delete: {e}") diff --git a/kokoro_gui/qt/document_state.py b/kokoro_gui/qt/document_state.py new file mode 100644 index 0000000..d5485f9 --- /dev/null +++ b/kokoro_gui/qt/document_state.py @@ -0,0 +1,19 @@ +"""Load-or-create logic for wiring a `kokoro_gui.daw.models.Document` into +`QtTTSApp`. Pure functions, no Qt imports - mirrors `kokoro_gui/qt/settings.py`'s +own split (app.py owns file-path constants and *when* to save; this module +just knows *how* to produce a `Document` to start from). +""" +from kokoro_gui.daw.migration import migrate_legacy_settings_to_document +from kokoro_gui.daw.serialization import load_document + + +def load_or_create_document(document_path: str, settings: dict, presets_dir: str): + """Loads `document_path` if it exists and parses; otherwise migrates + today's presets/settings into a fresh `Document` (see + `migrate_legacy_settings_to_document`). Migration is a one-time + bootstrap, not an ongoing sync - once a `document.json` exists, it's + trusted as-is even if `presets_dir`'s contents have since changed.""" + doc = load_document(document_path) + if doc is not None: + return doc + return migrate_legacy_settings_to_document(settings, presets_dir) diff --git a/kokoro_gui/qt/fx_presets.py b/kokoro_gui/qt/fx_presets.py new file mode 100644 index 0000000..c3e2790 --- /dev/null +++ b/kokoro_gui/qt/fx_presets.py @@ -0,0 +1,51 @@ +"""Shared FX-preset-name listing (`presets/fx/*.json`), factored out so both +`kokoro_gui.qt.docks.fx_dock.FXDock` (its preset combo) and +`kokoro_gui.qt.timeline_view.TimelineView` (item 5's per-clip FX button menu) +glob the same directory the same way, instead of duplicating the logic. + +Deliberately its own sibling module rather than living inside +`kokoro_gui/qt/docks/fx_dock.py` (as a first-pass reading of item 5's plan +suggests) or being imported by `timeline_view.py` from anywhere under +`kokoro_gui.qt.docks`: that package's `__init__.py` imports +`generation_dock.py` first, which does `import kokoro_gui.qt.app as +qt_app_module` - and `app.py` in turn does `from kokoro_gui.qt.docks import +(...)`, needing every dock class already bound. That round-trip only +resolves today because `app.py` is always the *first* module touched in +every real import chain (every dock's `import kokoro_gui.qt.app as +qt_app_module` is a safe, alias-only reference to a still-initializing +module). `timeline_view.py` is imported standalone by its own test file +(no `app.py` involved at all) - if it reached into `kokoro_gui.qt.docks. +fx_dock` directly, that would force `kokoro_gui/qt/docks/__init__.py` to run +before `app.py` has ever been touched, hitting exactly that unresolved +circular import. A plain sibling module (no dependency on the `docks` +package, and no *module-level* dependency on `app.py` either - see below) +sidesteps the whole problem. +""" +from __future__ import annotations + +import os + + +def list_fx_preset_names(project_dir: str | None = None) -> list[str]: + """Every FX preset's name (no extension), sorted: the open project's + `fx/` (grill TB3, project-local first) plus `presets/fx/*.json`. + + Reads `kokoro_gui.qt.app.FX_PRESETS_DIR` via a *local* import inside this + function (not a module-level one) purely to avoid this module ever + triggering `app.py`'s import at *this* module's own import time - by the + time any caller actually invokes this function, `app.py` is always + already fully loaded (both `FXDock` and `TimelineView` are only ever + constructed after it is). A test's + `monkeypatch.setattr(qt_app_module, "FX_PRESETS_DIR", ...)` is still + honored either way, since the attribute is read fresh on every call. + """ + import kokoro_gui.qt.app as qt_app_module + + names = set() + dirs = [qt_app_module.FX_PRESETS_DIR] + if project_dir: + dirs.insert(0, os.path.join(project_dir, "fx")) + for directory in dirs: + if os.path.isdir(directory): + names.update(f[:-5] for f in os.listdir(directory) if f.endswith(".json")) + return sorted(names) diff --git a/kokoro_gui/qt/fx_resolve.py b/kokoro_gui/qt/fx_resolve.py new file mode 100644 index 0000000..7d54b9e --- /dev/null +++ b/kokoro_gui/qt/fx_resolve.py @@ -0,0 +1,107 @@ +"""The one place a clip's (or character's) FX stack is resolved. + +Layers, lowest first: the Audio FX tab's project values, the character's +attached `fx_preset` file, the clip's own `overrides["fx_preset"]`, then +`clip.fx_override` (resolved values, set by the tab in clip scope or by the +timeline's FX menu). `QtTTSApp._assemble_clip_config` generates and +post-processes with the result and `FXDock._resolved_values` renders it, so +the dock can never show something the transport doesn't play. + +`apply_fx` is the project master switch (the Settings tab's "Apply" box) +ANDed with the scope's own value: a character's `preset_data["apply_fx"]`, +overridden by an explicit `clip.overrides["apply_fx"]`. A clip that carries +an `fx_override` counts as FX-on for that clip unless it also carries an +explicit `overrides["apply_fx"]` of False; applying FX to a clip and then +hearing nothing because its character's preset says off would be a puzzle, +not a feature. +""" +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from typing import Optional + +from kokoro_gui.engine.presets import ALLOWED_FX_PRESET_KEYS, filter_allowed_keys + +PLACEHOLDER = "Select FX Preset..." + + +@dataclass +class FxResolution: + values: dict = field(default_factory=dict) + preset_name: Optional[str] = None # the name the scope resolves to; "custom" for an fx_override + apply_fx: bool = True + + +def real_preset_name(name) -> Optional[str]: + """`name` unless it's empty or the combo placeholder that older + `preset_data`/`overrides` dicts still carry as `fx_preset`.""" + return name if name and name != PLACEHOLDER else None + + +def load_fx_preset_values(app, name) -> Optional[dict]: + """The whitelisted values of `presets/fx/.json`, via the engine + first, then the file directly (tests stub `load_fx_preset` to None).""" + name = real_preset_name(name) + if not name: + return None + project_dir = getattr(app, "project_dir", None) + preset = app.engine.load_fx_preset(name, project_dir) + if not preset: + import kokoro_gui.qt.app as qt_app_module + + safe = os.path.basename(name) + candidates = [os.path.join(qt_app_module.FX_PRESETS_DIR, f"{safe}.json")] + if project_dir: + candidates.insert(0, os.path.join(project_dir, "fx", f"{safe}.json")) + fpath = next((c for c in candidates if os.path.exists(c)), candidates[-1]) + if os.path.exists(fpath): + try: + with open(fpath, "r", encoding="utf-8") as fh: + preset = json.load(fh) + except Exception: + preset = None + return filter_allowed_keys(preset, ALLOWED_FX_PRESET_KEYS) if preset else None + + +def resolve_fx(app, clip=None, character=None) -> FxResolution: + """Resolve for a clip (its character is looked up), for a character + alone, or for the project when both are None.""" + fx_dock = getattr(app, "fx_dock", None) + settings_dock = getattr(app, "settings_dock", None) + values = dict(fx_dock.project_fx_state()) if fx_dock is not None else {} + apply_fx = bool(settings_dock.apply_fx_enabled()) if settings_dock is not None else True + # The project preset's name is only the answer at project scope; a + # character or clip without a preset of its own shows none (placeholder). + preset_name = real_preset_name(app.settings.get("fx_preset")) if clip is None and character is None else None + + if clip is not None and character is None: + character = app.document.get_character(clip.character_id) + + scope_apply = True + if character is not None: + name = real_preset_name(character.preset_data.get("fx_preset")) + preset = load_fx_preset_values(app, name) + if preset: + values.update(preset) + if name: + preset_name = name + scope_apply = bool(character.preset_data.get("apply_fx", True)) + + if clip is not None: + own_name = real_preset_name(clip.overrides.get("fx_preset")) + own = load_fx_preset_values(app, own_name) + if own: + values.update(own) + if own_name: + preset_name = own_name + if clip.fx_override: + values.update(filter_allowed_keys(clip.fx_override, ALLOWED_FX_PRESET_KEYS)) + scope_apply = True + if not own_name: + preset_name = "custom" + if "apply_fx" in clip.overrides: + scope_apply = bool(clip.overrides["apply_fx"]) + + return FxResolution(values=values, preset_name=preset_name, apply_fx=apply_fx and scope_apply) diff --git a/kokoro_gui/qt/icons.py b/kokoro_gui/qt/icons.py new file mode 100644 index 0000000..f6ebfe8 --- /dev/null +++ b/kokoro_gui/qt/icons.py @@ -0,0 +1,48 @@ +"""Vector icons painted at runtime, so the shell needs no icon files and no +icon-font dependency, and every glyph can be tinted to the active theme. + +`icon(name, color, size)` returns a `QIcon` for one of `ICON_NAMES` +("play", "pause", "stop") drawn as a filled shape in `color`. Pixmaps are +rendered at 2x for high-DPI screens. Callers re-request icons on +`themeChanged` (see `TransportDock._apply_icons`) since a `QIcon` holds +pixels, not a color token. +""" +from __future__ import annotations + +from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import QColor, QIcon, QPainter, QPainterPath, QPixmap, QPolygonF + +ICON_NAMES = ("play", "pause", "stop") +_SCALE = 2 + + +def _path(name: str, s: float) -> QPainterPath: + path = QPainterPath() + if name == "play": + path.addPolygon(QPolygonF([QPointF(s * 0.32, s * 0.18), QPointF(s * 0.86, s * 0.5), QPointF(s * 0.32, s * 0.82)])) + path.closeSubpath() + elif name == "pause": + path.addRoundedRect(QRectF(s * 0.24, s * 0.2, s * 0.2, s * 0.6), s * 0.05, s * 0.05) + path.addRoundedRect(QRectF(s * 0.56, s * 0.2, s * 0.2, s * 0.6), s * 0.05, s * 0.05) + elif name == "stop": + path.addRoundedRect(QRectF(s * 0.24, s * 0.24, s * 0.52, s * 0.52), s * 0.08, s * 0.08) + else: + raise ValueError(f"unknown icon {name!r}") + return path + + +def pixmap(name: str, color: str, size: int = 16) -> QPixmap: + px = QPixmap(size * _SCALE, size * _SCALE) + px.setDevicePixelRatio(_SCALE) + px.fill(Qt.GlobalColor.transparent) + painter = QPainter(px) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(color)) + painter.drawPath(_path(name, float(size))) + painter.end() + return px + + +def icon(name: str, color: str, size: int = 16) -> QIcon: + return QIcon(pixmap(name, color, size)) diff --git a/kokoro_gui/qt/project.py b/kokoro_gui/qt/project.py new file mode 100644 index 0000000..48da053 --- /dev/null +++ b/kokoro_gui/qt/project.py @@ -0,0 +1,1202 @@ +"""Project files for the File menu. Pure functions, no Qt. + +The project format is the `.tbaw` bundle (Claude/old/PLAN_tbaw_bundle.md, grill +TB1-TB15): one zip holding `manifest.json`, `document.json` +(`kokoro_gui.daw.serialization`'s shape), `project.json` (the +`project_settings` block: export defaults, workspace override, bundle +options), every generated segment under `audio/generated/` named by its +segment key, and every named asset a character or clip points at (`fx/`, +`engines//...`). A `.json` project (the 4.0-preview format, the document +shape plus a top-level `"project_settings"`) still opens and is migrated to +`.tbaw` on open (`migrate_json_project`). + +The live project is a directory, `cache/projects//` (the +"project dir"), extracted from the zip on Open and written by autosave +(`document.json`, `project.json`) and by clip generation +(`audio/generated/`). Save rewrites the whole zip from it. While a project +is open the app holds an OS lock on `/lock`, and +`session.json` next to it records what the dir belongs to +(`source_path`, the zip's size and mtime at extraction), the digest of the +last saved document, whether the dir is ahead of the zip (`dirty`), and an +`asset_index` so Save can skip re-hashing an unchanged asset. Recovery after +a crash keys on `project_id`, not on the path. + +`settings["last_project"]` is what launch reopens (WF2, revised: the +welcome dialog offers it as Resume); `settings["recent_projects"]` is the +File > Recent list and the welcome dialog's rows, most recent first, at +most `MAX_RECENT` entries. The app (kokoro_gui/qt/app.py) owns the +sequencing and the threads: the functions here are the steps. +""" +from __future__ import annotations + +import datetime +import hashlib +import json +import os +import secrets +import shutil +import sys +import time +import zipfile +from dataclasses import dataclass, field + +import kokoro_engine +from kokoro_gui import APP_VERSION +from kokoro_gui.daw import serialization +from kokoro_gui.daw.models import Document +from kokoro_gui.engine.caching import RESERVED_SUFFIX, compute_cache_key, effective_speed + +MAX_RECENT = 10 +PROJECT_FILTER = "KokoroGUI project (*.tbaw *.json)" +DEFAULT_EXTENSION = ".tbaw" + +FORMAT = "tbaw" +SUPPORTED_VERSION = 1 +# Content features this reader implements; a bundle whose `requires` names +# one that isn't here is refused by name (section 8 of the plan). +SUPPORTED_FEATURES: frozenset = frozenset() + +MANIFEST = "manifest.json" +DOCUMENT = "document.json" +PROJECT_JSON = "project.json" +SESSION = "session.json" +LOCK = "lock" +AUDIO_GENERATED = "audio/generated" +AUDIO_IMPORTED = "audio/imported" +FX_DIR = "fx" +ENGINES_DIR = "engines" + +# Entry prefixes this version owns and rewrites on every Save. Anything else +# in a bundle (a directory a newer KokoroGUI or a fourth engine added) is +# copied through byte for byte so a file survives a round trip. +_OWNED_FILES = {MANIFEST, DOCUMENT, PROJECT_JSON} +_OWNED_DIRS = (FX_DIR + "/", AUDIO_GENERATED + "/", AUDIO_IMPORTED + "/") +# The project dir's own bookkeeping. A bundle carrying one of these names is +# never extracted over it: `lock` is held open while Open runs, and +# `session.json` is what the sweep and the recover prompt trust. +_DIR_PRIVATE = {SESSION, LOCK, SESSION + ".tmp", DOCUMENT + ".tmp", PROJECT_JSON + ".tmp"} + +DEFAULT_BUNDLE_OPTIONS = {"include_generated_audio": True, "include_imported_audio": True, "audio_format": "wav"} + +# `torch.load` defaults to `weights_only=True` from 2.6, which is what makes +# a `.pt` from someone else's bundle safe to load. Checked once at import. +_TORCH_VERSION_OK: bool | None = None + + +class ProjectError(Exception): + """A bundle this version can't or won't open; the message is for the user.""" + + +class ProjectLockedError(ProjectError): + """Another KokoroGUI holds the project dir's lock.""" + + +@dataclass +class LoadedProject: + document: Document + project_settings: dict = field(default_factory=dict) + project_dir: str | None = None + project_id: str | None = None + manifest: dict = field(default_factory=dict) + # One-line notices for the status bar (a missing audio file, a version + # difference), not errors. + notices: list = field(default_factory=list) + + +@dataclass +class BundleInfo: + """What `inspect_bundle` learns from a zip's central directory without + extracting a byte.""" + path: str + manifest: dict + project_id: str + entries: list # ZipInfo, validated + audio_bytes: int + zip_size: int + zip_mtime: float + + +@dataclass +class SaveResult: + zip_size: int + zip_mtime: float + asset_index: dict + saved_digest: str + + +# --- paths and ids --------------------------------------------------------------- + + +def format_for_path(path: str) -> str: + ext = os.path.splitext(path)[1].lower() + if ext == ".tbaw": + return "tbaw" + return "json" + + +def projects_root() -> str: + """`cache/projects/` under `kokoro_engine.CACHE_DIR`, read at call time + so the `isolated_dirs` fixture redirects it. Absolute: every + `audio_path` is built on it and Save tells project-dir files from + outside ones by prefix.""" + return os.path.abspath(os.path.join(kokoro_engine.CACHE_DIR, "projects")) + + +def new_project_id() -> str: + return secrets.token_hex(8) + + +def project_title(path: str | None) -> str: + if not path: + return "Untitled" + return os.path.splitext(os.path.basename(path))[0] or "Untitled" + + +def bundle_path_for(path: str) -> str: + """The `.tbaw` name for any project path: `.json` becomes `.tbaw`, a + bare name gets the extension.""" + root, ext = os.path.splitext(path) + if ext.lower() in (".tbaw", ".json"): + return root + DEFAULT_EXTENSION + return path + DEFAULT_EXTENSION + + +def torch_weights_only_available() -> bool: + global _TORCH_VERSION_OK + if _TORCH_VERSION_OK is None: + try: + import torch + + major, minor = (int(x) for x in torch.__version__.split("+")[0].split(".")[:2]) + _TORCH_VERSION_OK = (major, minor) >= (2, 6) + except Exception: + _TORCH_VERSION_OK = False + return _TORCH_VERSION_OK + + +# --- recent list ----------------------------------------------------------------- + + +def remember_recent(settings: dict, path: str) -> list: + """Moves `path` to the front of `settings["recent_projects"]`, dropping + duplicates and trimming to `MAX_RECENT`. Returns the new list.""" + path = os.path.abspath(path) + recent = [p for p in settings.get("recent_projects", []) if isinstance(p, str) and os.path.abspath(p) != path] + recent.insert(0, path) + settings["recent_projects"] = recent[:MAX_RECENT] + settings["last_project"] = path + return settings["recent_projects"] + + +def forget_recent(settings: dict, path: str) -> None: + path = os.path.abspath(path) + settings["recent_projects"] = [p for p in settings.get("recent_projects", []) if os.path.abspath(p) != path] + + +def clear_recent(settings: dict) -> None: + """Empties the list. `last_project` stays: the open project is still the + one launch resumes, it just isn't listed any more.""" + settings["recent_projects"] = [] + + +# --- .json (legacy) -------------------------------------------------------------- + + +def load_json_project(path: str) -> LoadedProject | None: + """`None` when the file is missing or unreadable, same tolerance + `serialization.load_document` has.""" + if not path or not os.path.exists(path): + return None + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(data, dict): + return None + document = serialization.document_from_dict(data) + project_settings = data.get("project_settings", {}) + return LoadedProject(document=document, + project_settings=dict(project_settings) if isinstance(project_settings, dict) else {}) + + +def save_json_project(document: Document, path: str, project_settings: dict | None = None) -> None: + """The 4.0-preview format, kept for the migration tests and for anyone who + wants a plain-JSON export; the app never writes it any more.""" + data = serialization.document_to_dict(document) + data["project_settings"] = dict(project_settings or {}) + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + +def load_project(path: str) -> LoadedProject | None: + """Reads a project's document and settings without a project dir: the + `.json` format directly, a `.tbaw` straight out of the zip (audio paths + left bundle-relative, so segments read as missing). The app's Open goes + through `inspect_bundle`/`extract_*`/`finish_open` instead; this is for + callers that only need the document (tests, tooling).""" + if not path or not os.path.exists(path): + return None + if format_for_path(path) != "tbaw": + return load_json_project(path) + try: + with zipfile.ZipFile(path) as zf: + manifest = read_manifest_from(zf) + document = serialization.document_from_dict(json.loads(zf.read(DOCUMENT).decode("utf-8"))) + project_settings = {} + if PROJECT_JSON in zf.namelist(): + project_settings = json.loads(zf.read(PROJECT_JSON).decode("utf-8")) + except (OSError, zipfile.BadZipFile, KeyError, json.JSONDecodeError, ProjectError): + return None + return LoadedProject(document=document, project_settings=project_settings, + project_id=manifest.get("project_id"), manifest=manifest) + + +# --- manifest --------------------------------------------------------------------- + + +def read_manifest_from(zf: zipfile.ZipFile) -> dict: + try: + manifest = json.loads(zf.read(MANIFEST).decode("utf-8")) + except KeyError: + raise ProjectError("Not a KokoroGUI project: no manifest.json in the bundle.") + except (json.JSONDecodeError, UnicodeDecodeError): + raise ProjectError("The bundle's manifest.json is not valid JSON.") + if not isinstance(manifest, dict) or manifest.get("format") != FORMAT: + raise ProjectError("Not a KokoroGUI project: the manifest's format isn't \"tbaw\".") + version = manifest.get("version", 0) + if not isinstance(version, int) or version > SUPPORTED_VERSION: + raise ProjectError(f"This project was made with a newer KokoroGUI (bundle version {version}, " + f"this build reads up to {SUPPORTED_VERSION}).") + requires = manifest.get("requires", []) or [] + unknown = [str(r) for r in requires if str(r) not in SUPPORTED_FEATURES] + if unknown: + raise ProjectError("This project needs a feature this KokoroGUI doesn't have: " + ", ".join(unknown)) + if not manifest.get("project_id"): + manifest["project_id"] = new_project_id() + return manifest + + +def _entry_name_is_safe(name: str) -> bool: + normalized = name.replace("\\", "/") + if not normalized or normalized.endswith("/") and normalized.count("/") == 0: + return True + if os.path.isabs(normalized) or normalized.startswith("/"): + return False + if len(normalized) >= 2 and normalized[1] == ":": + return False # drive-relative on Windows (C:foo) + parts = normalized.split("/") + if any(part == ".." for part in parts): + return False + return True + + +def _is_symlink_entry(info: zipfile.ZipInfo) -> bool: + mode = (info.external_attr >> 16) & 0o170000 + return mode == 0o120000 + + +def validate_entries(zf: zipfile.ZipFile, project_dir: str) -> list: + """Every entry, or `ProjectError` for one that would write outside the + project dir (absolute on either OS, drive-relative, `..`, a symlink).""" + root = os.path.realpath(project_dir) + entries = [] + for info in zf.infolist(): + name = info.filename + if not _entry_name_is_safe(name): + raise ProjectError(f"Refusing to open: the bundle has an unsafe entry name ({name!r}).") + if _is_symlink_entry(info): + raise ProjectError(f"Refusing to open: the bundle contains a symlink ({name!r}).") + target = os.path.realpath(os.path.join(root, *name.replace("\\", "/").split("/"))) + if target != root and not target.startswith(root + os.sep): + raise ProjectError(f"Refusing to open: an entry resolves outside the project dir ({name!r}).") + entries.append(info) + return entries + + +def free_space(path: str) -> int: + probe = path + while probe and not os.path.exists(probe): + parent = os.path.dirname(probe) + if parent == probe: + break + probe = parent + try: + return shutil.disk_usage(probe or ".").free + except OSError: + return sys.maxsize + + +def check_free_space(path: str, needed: int, what: str) -> None: + """`ProjectError` before a byte is written when the volume holding + `path` can't take `needed` bytes (plus a small margin).""" + margin = 32 * 1024 * 1024 + available = free_space(path) + if available < needed + margin: + raise ProjectError(f"Not enough free space to {what}: needs about {needed // (1024 * 1024)} MB, " + f"{available // (1024 * 1024)} MB free on {os.path.dirname(os.path.abspath(path)) or path}.") + + +def inspect_bundle(path: str) -> BundleInfo: + """Step 1 and 3 of Open: the manifest, validated entries and the audio + byte count, all from the central directory.""" + if not path or not os.path.isfile(path): + raise ProjectError(f"{path} doesn't exist.") + try: + zf = zipfile.ZipFile(path) + except (OSError, zipfile.BadZipFile): + raise ProjectError(f"{os.path.basename(path)} isn't a readable .tbaw bundle.") + with zf: + manifest = read_manifest_from(zf) + project_id = manifest["project_id"] + entries = validate_entries(zf, os.path.join(projects_root(), project_id)) + if DOCUMENT not in zf.namelist(): + raise ProjectError("The bundle has no document.json.") + if any(e.filename.endswith(".pt") for e in entries) and not torch_weights_only_available(): + raise ProjectError("This bundle carries a voice mix (.pt) and this machine's torch is older than 2.6, " + "which can't load it safely. Upgrade torch to open it.") + audio_bytes = sum(e.file_size for e in entries if e.filename.startswith("audio/")) + stat = os.stat(path) + return BundleInfo(path=os.path.abspath(path), manifest=manifest, project_id=project_id, entries=entries, + audio_bytes=audio_bytes, zip_size=stat.st_size, zip_mtime=stat.st_mtime) + + +# --- project dir, session, lock --------------------------------------------------- + + +def read_session(project_dir: str) -> dict | None: + path = os.path.join(project_dir, SESSION) + if not os.path.isfile(path): + return None + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def write_session(project_dir: str, data: dict) -> None: + """The GUI thread is the one writer; a background Save hands its + numbers back for the GUI side to record.""" + os.makedirs(project_dir, exist_ok=True) + tmp = os.path.join(project_dir, SESSION + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(tmp, os.path.join(project_dir, SESSION)) + + +def choose_project_dir(project_id: str, source_path: str | None) -> str: + """`cache/projects//`, or `-2`, `-3`, ... when + that dir's session names a different `source_path` and is clean (a Save + As left two files sharing one id). A dirty dir with another path is + still this project's: the id matched, that's what recovery keys on.""" + root = projects_root() + suffix = 1 + while True: + candidate = os.path.join(root, project_id if suffix == 1 else f"{project_id}-{suffix}") + session = read_session(candidate) + if session is None: + return candidate + theirs = session.get("source_path") + same_source = (not theirs and not source_path) or bool( + theirs and source_path and os.path.abspath(theirs) == os.path.abspath(source_path)) + if same_source or session.get("dirty"): + return candidate + suffix += 1 + + +class ProjectLock: + """An OS advisory lock on `/lock`, held open for as long as + the project is. The OS releases it when the process dies, so there is + no pid to check and no stale-lock heuristic; a second instance whose + attempt fails is told the project is open elsewhere.""" + + def __init__(self, project_dir: str): + self.project_dir = project_dir + self.path = os.path.join(project_dir, LOCK) + self._handle = None + + def acquire(self) -> "ProjectLock": + os.makedirs(self.project_dir, exist_ok=True) + handle = open(self.path, "a+b") + try: + if sys.platform == "win32": + import msvcrt + + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + handle.close() + raise ProjectLockedError("This project is already open in another KokoroGUI window.") + self._handle = handle + return self + + def release(self) -> None: + handle, self._handle = self._handle, None + if handle is None: + return + try: + if sys.platform == "win32": + import msvcrt + + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + except OSError: + pass + handle.close() + + @property + def held(self) -> bool: + return self._handle is not None + + +def is_locked(project_dir: str) -> bool: + if not os.path.isfile(os.path.join(project_dir, LOCK)): + return False + try: + ProjectLock(project_dir).acquire().release() + except ProjectLockedError: + return True + except OSError: + return False + return False + + +def wipe_project_dir(project_dir: str) -> None: + """Everything except `lock`, which the holder has open (on Windows + `rmtree` over an open locked handle raises).""" + if not os.path.isdir(project_dir): + return + for entry in os.listdir(project_dir): + if entry == LOCK: + continue + full = os.path.join(project_dir, entry) + if os.path.isdir(full) and not os.path.islink(full): + shutil.rmtree(full, ignore_errors=True) + else: + try: + os.remove(full) + except OSError: + pass + + +def delete_project_dir(project_dir: str) -> None: + wipe_project_dir(project_dir) + try: + os.remove(os.path.join(project_dir, LOCK)) + except OSError: + pass + try: + os.rmdir(project_dir) + except OSError: + pass + + +def create_project_dir(project_id: str | None = None) -> tuple: + """New: a fresh dir with an empty `document.json`, so an Untitled + project has somewhere to generate into before its first Save. Returns + `(project_dir, project_id)`.""" + project_id = project_id or new_project_id() + project_dir = os.path.join(projects_root(), project_id) + os.makedirs(os.path.join(project_dir, AUDIO_GENERATED), exist_ok=True) + return project_dir, project_id + + +def document_digest(document_bytes: bytes, project_bytes: bytes) -> str: + return hashlib.sha256(document_bytes + b"\n" + project_bytes).hexdigest() + + +def serialize_for_dir(document: Document, project_settings: dict, project_dir: str) -> tuple: + """`(document_bytes, project_bytes)` as autosave writes them: audio + paths absolute inside the project dir stay absolute in the dir's copy + (they're rewritten to bundle-relative only in the zip).""" + data = serialization.document_to_dict(document) + document_bytes = json.dumps(data, indent=2).encode("utf-8") + project_bytes = json.dumps(dict(project_settings or {}), indent=2).encode("utf-8") + return document_bytes, project_bytes + + +def autosave_to_dir(document: Document, project_settings: dict, project_dir: str) -> str: + """Writes `document.json` and `project.json` into the project dir and + returns the digest of what was written, for the app to compare with + `session.json`'s `saved_digest`.""" + document_bytes, project_bytes = serialize_for_dir(document, project_settings, project_dir) + os.makedirs(project_dir, exist_ok=True) + for name, payload in ((DOCUMENT, document_bytes), (PROJECT_JSON, project_bytes)): + tmp = os.path.join(project_dir, name + ".tmp") + with open(tmp, "wb") as f: + f.write(payload) + os.replace(tmp, os.path.join(project_dir, name)) + return document_digest(document_bytes, project_bytes) + + +# --- open -------------------------------------------------------------------------- + + +def _extract_entry(zf: zipfile.ZipFile, info: zipfile.ZipInfo, project_dir: str) -> None: + name = info.filename.replace("\\", "/") + target = os.path.join(project_dir, *name.split("/")) + if name.endswith("/"): + os.makedirs(target, exist_ok=True) + return + os.makedirs(os.path.dirname(target), exist_ok=True) + with zf.open(info) as src, open(target, "wb") as dst: + shutil.copyfileobj(src, dst, 1024 * 1024) + + +def extract_small(info: BundleInfo, project_dir: str) -> None: + """Step 4: everything but `audio/`. Small, needed before the first paint.""" + os.makedirs(project_dir, exist_ok=True) + with zipfile.ZipFile(info.path) as zf: + for entry in info.entries: + name = entry.filename.replace("\\", "/") + if name.startswith("audio/") or name in _DIR_PRIVATE: + continue + _extract_entry(zf, entry, project_dir) + + +def extract_audio(info: BundleInfo, project_dir: str, progress=None, cancelled=None) -> None: + """Step 5: `audio/`, eagerly, meant for a worker thread. `progress(done, + total)` in bytes; `cancelled()` is polled between entries.""" + total = max(1, info.audio_bytes) + done = 0 + with zipfile.ZipFile(info.path) as zf: + for entry in info.entries: + if cancelled is not None and cancelled(): + return + if not entry.filename.replace("\\", "/").startswith("audio/"): + continue + _extract_entry(zf, entry, project_dir) + done += entry.file_size + if progress is not None: + progress(done, total) + + +def finish_open(info: BundleInfo, project_dir: str, engine_versions: dict | None = None, + recovered: bool = False) -> LoadedProject: + """Steps 6 and 7: the document from the project dir with paths made + absolute (a missing file becomes `None`, so the clip reads as dirty), + `project.json`, a fresh `session.json` unless the session was recovered + (then it stays as it is, `dirty` included), and the TB9 notice when a + manifest engine version differs from `engine_versions[id]`.""" + with open(os.path.join(project_dir, DOCUMENT), "r", encoding="utf-8") as f: + data = json.load(f) + project_settings = {} + project_json = os.path.join(project_dir, PROJECT_JSON) + if os.path.isfile(project_json): + try: + with open(project_json, "r", encoding="utf-8") as f: + project_settings = json.load(f) + except (OSError, json.JSONDecodeError): + project_settings = {} + if not isinstance(project_settings, dict): + project_settings = {} + + notices = [] + missing = [] + project_root = os.path.realpath(project_dir) + + def to_absolute(rel): + # A bundle names its audio relative to the bundle; the dir's own + # autosave names it absolute. Either way the file has to sit inside + # the project dir: `document.json` is untrusted input, and a path + # pointing anywhere else would pull that file into the next Save. + if os.path.isabs(rel): + candidate = rel + else: + candidate = os.path.join(project_dir, *rel.replace("\\", "/").split("/")) + real = os.path.realpath(candidate) + if real.startswith(project_root + os.sep) and os.path.isfile(real): + return os.path.abspath(candidate) + missing.append(rel) + return None + + serialization.rewrite_audio_paths(data, to_absolute) + document = serialization.document_from_dict(data) + if missing: + notices.append(f"{len(missing)} audio file(s) missing from the bundle; those clips will regenerate.") + + for engine_id, block in (info.manifest.get("engines") or {}).items(): + if not isinstance(block, dict): + continue + made_with = block.get("version") + installed = (engine_versions or {}).get(engine_id) + if made_with and installed and made_with != installed: + notices.append(f"generated with {engine_id} {made_with}, this machine has {installed}; " + f"regenerated clips will use {installed}") + + if not recovered: + document_bytes, project_bytes = serialize_for_dir(document, project_settings, project_dir) + previous = read_session(project_dir) or {} + write_session(project_dir, { + "source_path": info.path, + "zip_size": info.zip_size, + "zip_mtime": info.zip_mtime, + "saved_digest": document_digest(document_bytes, project_bytes), + "dirty": False, + "asset_index": previous.get("asset_index", {}) if isinstance(previous.get("asset_index"), dict) else {}, + }) + + return LoadedProject(document=document, project_settings=project_settings, project_dir=project_dir, + project_id=info.project_id, manifest=info.manifest, notices=notices) + + +def session_matches_file(session: dict | None, info: BundleInfo) -> bool: + """True when the dir was extracted from the file as it is now (size and + mtime unchanged), so a clean dir can be reused without extracting.""" + if not session: + return False + try: + return (int(session.get("zip_size", -1)) == info.zip_size + and abs(float(session.get("zip_mtime", -1)) - info.zip_mtime) < 1e-6) + except (TypeError, ValueError): + return False + + +# --- save -------------------------------------------------------------------------- + + +def _sha256_file(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for block in iter(lambda: f.read(1024 * 1024), b""): + h.update(block) + return "sha256:" + h.hexdigest() + + +def used_voice_names(document: Document) -> dict: + """`{backend_id: {voice name, ...}}` from every character's preset and + every clip override, grouped by the clip's character's backend.""" + names: dict = {} + for character in document.characters: + voice = (character.preset_data or {}).get("voice") + if voice: + names.setdefault(character.backend_id or "kokoro", set()).add(str(voice)) + for clip in document.clips: + voice = (clip.overrides or {}).get("voice") + if voice: + character = document.get_character(clip.character_id) + backend_id = (character.backend_id if character else None) or "kokoro" + names.setdefault(backend_id, set()).add(str(voice)) + return names + + +def used_fx_preset_names(document: Document) -> set: + names = set() + for character in document.characters: + preset = (character.preset_data or {}).get("fx_preset") + if preset: + names.add(str(preset)) + for clip in document.clips: + preset = (clip.overrides or {}).get("fx_preset") + if preset: + names.add(str(preset)) + return names + + +def collect_assets(document: Document, backend_for, project_dir: str | None, fx_presets_dir: str) -> tuple: + """`(assets, engines, warnings)`: every `(bundle_path, source_path)` the + document needs, the `manifest.engines` block (version + meta per used + backend), and one warning per asset that resolves nowhere. `backend_for(id)` + returns an adapter or `None` for an engine that isn't registered.""" + assets = [] + engines = {} + warnings = [] + for backend_id, names in sorted(used_voice_names(document).items()): + backend = backend_for(backend_id) + if backend is None: + warnings.append(f"engine {backend_id!r} isn't installed; its voices aren't bundled") + continue + try: + found, meta = backend.collect_project_assets(set(names), project_dir) + except Exception as e: # noqa: BLE001 - one backend's failure shouldn't stop a Save + warnings.append(f"{backend_id}: couldn't collect voice files ({e})") + found, meta = [], {} + bundled = {os.path.splitext(os.path.basename(a.bundle_path))[0] for a in found} + for name in sorted(names): + if os.path.basename(name) not in bundled and backend.resolve_voice_file(name, project_dir) is None \ + and getattr(backend.capabilities, "supports_voice_cloning", False): + warnings.append(f"voice reference {name!r} not found; not bundled") + assets.extend((a.bundle_path, a.source_path) for a in found) + engines[backend_id] = {"version": backend.engine_version(), "meta": dict(meta or {})} + for name in sorted(used_fx_preset_names(document)): + safe = os.path.basename(name) + candidates = [] + if project_dir: + candidates.append(os.path.join(project_dir, FX_DIR, f"{safe}.json")) + candidates.append(os.path.join(fx_presets_dir, f"{safe}.json")) + source = next((c for c in candidates if os.path.isfile(c)), None) + if source is None: + warnings.append(f"FX preset {name!r} not found; not bundled") + continue + assets.append((f"{FX_DIR}/{safe}.json", os.path.abspath(source))) + return assets, engines, warnings + + +def bundle_options(project_settings: dict) -> dict: + options = dict(DEFAULT_BUNDLE_OPTIONS) + block = project_settings.get("bundle") if isinstance(project_settings, dict) else None + if isinstance(block, dict): + options.update({k: v for k, v in block.items() if k in DEFAULT_BUNDLE_OPTIONS}) + if options["audio_format"] not in ("wav", "flac"): + options["audio_format"] = "wav" + return options + + +def project_stats(document: Document) -> dict: + """Cosmetic, for the welcome dialog: list lengths and the sum of + `Segment.duration`. Nothing here reads audio.""" + duration = 0.0 + for clip in document.clips: + for segment in clip.segments: + duration += float(segment.duration or 0.0) + return {"clips": len(document.clips), "characters": len(document.characters), "duration_s": round(duration, 3)} + + +@dataclass +class SavePlan: + """Everything Save needs, assembled on the GUI thread (a snapshot) so the + worker never reads dock state or the live document.""" + path: str + project_dir: str + project_id: str + manifest: dict + document_bytes: bytes + project_bytes: bytes + assets: list # (bundle_path, source_path) + audio_files: list # (bundle_path, source_path) + previous_asset_index: dict + # Digest of the document as autosave writes it into the project dir + # (absolute paths), which is what `session.json`'s `saved_digest` + # compares against; the zip's copy has bundle-relative paths. + dir_digest: str = "" + + +def plan_save(document: Document, project_settings: dict, path: str, project_dir: str, project_id: str, + backend_for, fx_presets_dir: str, previous_session: dict | None = None, + previous_manifest: dict | None = None) -> tuple: + """`(SavePlan, warnings)`. Serializes the document with bundle-relative + audio paths, collects assets and the referenced audio files.""" + options = bundle_options(project_settings) + data = serialization.document_to_dict(document) + audio_files = [] + seen = set() + project_root = os.path.realpath(project_dir) if project_dir else None + + def to_relative(abs_path): + # Only a file inside the project dir goes into the bundle. Every + # generated or migrated segment lives there; a path anywhere else + # can only have come from a hand-edited or crafted document, and + # bundling it would ship that file. It's left as written, so Open + # reports it missing. + if not os.path.isabs(abs_path): + return abs_path.replace("\\", "/") + real = os.path.realpath(abs_path) + if project_root and real.startswith(project_root + os.sep) and os.path.isfile(real): + rel = os.path.relpath(real, project_root).replace("\\", "/") + if rel not in seen: + seen.add(rel) + audio_files.append((rel, real)) + return rel + return abs_path.replace("\\", "/") + + serialization.rewrite_audio_paths(data, to_relative) + if not options["include_generated_audio"]: + audio_files = [a for a in audio_files if not a[0].startswith(AUDIO_GENERATED + "/")] + if not options["include_imported_audio"]: + audio_files = [a for a in audio_files if not a[0].startswith(AUDIO_IMPORTED + "/")] + + assets, engines, warnings = collect_assets(document, backend_for, project_dir, fx_presets_dir) + now = datetime.datetime.now().replace(microsecond=0).isoformat() + created = (previous_manifest or {}).get("created") or now + manifest = { + "format": FORMAT, + "version": SUPPORTED_VERSION, + "requires": [], + "project_id": project_id, + "created_by": f"KokoroGUI {APP_VERSION}", + "created": created, + "modified": now, + "includes": { + "generated_audio": bool(options["include_generated_audio"]), + "imported_audio": bool(options["include_imported_audio"]), + }, + "audio": {"format": options["audio_format"]}, + "stats": project_stats(document), + "engines": engines, + "assets": {}, # filled by write_bundle once hashed + } + dir_document, dir_project = serialize_for_dir(document, project_settings, project_dir) + plan = SavePlan( + path=os.path.abspath(path), project_dir=project_dir, project_id=project_id, manifest=manifest, + document_bytes=json.dumps(data, indent=2).encode("utf-8"), + project_bytes=json.dumps(dict(project_settings or {}), indent=2).encode("utf-8"), + assets=assets, audio_files=audio_files, + previous_asset_index=dict((previous_session or {}).get("asset_index") or {}), + dir_digest=document_digest(dir_document, dir_project), + ) + return plan, warnings + + +def _owned_entry(name: str, known_engine_ids) -> bool: + name = name.replace("\\", "/") + if name in _OWNED_FILES or name.startswith(_OWNED_DIRS): + return True + for engine_id in known_engine_ids: + if name.startswith(f"{ENGINES_DIR}/{engine_id}/"): + return True + return False + + +def _replace_with_retries(src: str, dst: str) -> None: + """`os.replace`, retried over ~2 s on Windows where a sync client or a + scanner holding the old file raises `PermissionError`.""" + attempts = 8 if sys.platform == "win32" else 1 + for attempt in range(attempts): + try: + os.replace(src, dst) + return + except PermissionError: + if attempt == attempts - 1: + raise + time.sleep(0.25) + + +def write_bundle(plan: SavePlan, known_engine_ids, progress=None) -> SaveResult: + """Steps 1-4 of Save, meant for a worker thread: hash assets not in the + previous index, check free space, write `.tmp` (JSON deflated, + audio stored, unknown entries from the old file copied through), then + replace. A crash mid-save leaves the old file intact; a replace that + keeps failing leaves the `.tmp` and says where it is.""" + asset_index = {} + manifest = dict(plan.manifest) + manifest["assets"] = {} + for bundle_path, source in plan.assets: + try: + stat = os.stat(source) + except OSError: + continue + previous = plan.previous_asset_index.get(bundle_path) + if isinstance(previous, list) and len(previous) == 3 and previous[0] == stat.st_size \ + and abs(float(previous[1]) - stat.st_mtime) < 1e-6: + digest = previous[2] + else: + digest = _sha256_file(source) + asset_index[bundle_path] = [stat.st_size, stat.st_mtime, digest] + manifest["assets"][bundle_path] = digest + + needed = len(plan.document_bytes) + len(plan.project_bytes) + for _bundle_path, source in plan.assets + plan.audio_files: + try: + needed += os.path.getsize(source) + except OSError: + pass + previous_path = plan.path if os.path.isfile(plan.path) else None + carried = [] + if previous_path: + try: + with zipfile.ZipFile(previous_path) as old: + for info in old.infolist(): + if not _owned_entry(info.filename, known_engine_ids): + carried.append(info.filename) + needed += info.file_size + except (OSError, zipfile.BadZipFile): + carried = [] + check_free_space(plan.path, needed, "save the project") + + tmp = plan.path + ".tmp" + parent = os.path.dirname(plan.path) + if parent: + os.makedirs(parent, exist_ok=True) + total = max(1, needed) + done = 0 + try: + with zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as zf: + zf.writestr(MANIFEST, json.dumps(manifest, indent=2)) + zf.writestr(DOCUMENT, plan.document_bytes) + zf.writestr(PROJECT_JSON, plan.project_bytes) + for bundle_path, source in plan.assets: + if os.path.isfile(source): + zf.write(source, bundle_path, compress_type=zipfile.ZIP_DEFLATED) + done += os.path.getsize(source) + for bundle_path, source in plan.audio_files: + if not os.path.isfile(source): + continue + zf.write(source, bundle_path, compress_type=zipfile.ZIP_STORED) + done += os.path.getsize(source) + if progress is not None: + progress(done, total) + if carried: + with zipfile.ZipFile(previous_path) as old: + for name in carried: + info = old.getinfo(name) + if info.is_dir(): + zf.writestr(info, b"") + continue + with old.open(info) as src, zf.open(zipfile.ZipInfo(info.filename, info.date_time), "w") as dst: + shutil.copyfileobj(src, dst, 1024 * 1024) + done += info.file_size + _replace_with_retries(tmp, plan.path) + except PermissionError as e: + raise ProjectError(f"Couldn't replace {plan.path} (another program holds it): {e}. " + f"The new version is at {tmp}.") + except BaseException: + try: + os.remove(tmp) + except OSError: + pass + raise + stat = os.stat(plan.path) + return SaveResult(zip_size=stat.st_size, zip_mtime=stat.st_mtime, asset_index=asset_index, + saved_digest=plan.dir_digest) + + +def record_save(project_dir: str, path: str, result: SaveResult) -> None: + """Step 5, on the GUI thread: `session.json` after a Save.""" + session = read_session(project_dir) or {} + session.update({ + "source_path": os.path.abspath(path), "zip_size": result.zip_size, "zip_mtime": result.zip_mtime, + "saved_digest": result.saved_digest, "dirty": False, "asset_index": result.asset_index, + }) + write_session(project_dir, session) + + +def save_project(document: Document, path: str, project_settings: dict | None = None, + project_dir: str | None = None, project_id: str | None = None, + backend_for=None, fx_presets_dir: str = os.path.join("presets", "fx"), + known_engine_ids=()) -> SaveResult: + """Synchronous Save for callers without an app (tests, tooling): plans + and writes in one go. A `.json` path is written in the legacy shape.""" + if format_for_path(path) != "tbaw": + save_json_project(document, path, project_settings) + return SaveResult(zip_size=os.path.getsize(path), zip_mtime=os.path.getmtime(path), asset_index={}, + saved_digest="") + project_settings = dict(project_settings or {}) + if project_dir is None: + project_dir, project_id = create_project_dir(project_id) + plan, _warnings = plan_save(document, project_settings, path, project_dir, project_id or new_project_id(), + backend_for or (lambda _id: None), fx_presets_dir, read_session(project_dir)) + result = write_bundle(plan, known_engine_ids) + record_save(project_dir, path, result) + return result + + +# --- close-time GC, eviction, sweep --------------------------------------------- + + +def referenced_audio_paths(document: Document) -> set: + paths = set() + for clip in document.clips: + if clip.original_audio_path: + paths.add(os.path.realpath(clip.original_audio_path)) + for segment in clip.segments: + if segment.audio_path: + paths.add(os.path.realpath(segment.audio_path)) + return paths + + +def gc_project_dir(project_dir: str, document: Document) -> list: + """Deletes every file under `audio/generated/` that no segment + references (regenerated-over takes, cancelled attempts, stale + reservation markers). Only at a clean close: the undo stack may still + point at any of them while the session lives (TB11). Returns what it + removed.""" + generated = os.path.join(project_dir, *AUDIO_GENERATED.split("/")) + if not os.path.isdir(generated): + return [] + keep = referenced_audio_paths(document) + removed = [] + for name in os.listdir(generated): + full = os.path.join(generated, name) + if not os.path.isfile(full): + continue + if os.path.realpath(full) in keep and not name.endswith(RESERVED_SUFFIX): + continue + try: + os.remove(full) + removed.append(full) + except OSError: + pass + return removed + + +def evict_project_dirs(keep_project_dir: str | None) -> list: + """TB13: on a clean close every dir under `cache/projects/` except the + one to keep (the `last_project`'s) is deleted, unless it's locked by + another window or dirty (a crash's recovery data). Returns the removed + dirs.""" + root = projects_root() + if not os.path.isdir(root): + return [] + keep = os.path.realpath(keep_project_dir) if keep_project_dir else None + removed = [] + for name in os.listdir(root): + full = os.path.join(root, name) + if not os.path.isdir(full) or (keep and os.path.realpath(full) == keep): + continue + session = read_session(full) + if session and session.get("dirty"): + continue + if is_locked(full): + continue + delete_project_dir(full) + removed.append(full) + return removed + + +def sweep_orphan_dirs() -> list: + """On Open: any clean, unlocked dir whose `source_path` no longer exists + (a crash after the file was moved) is deleted.""" + root = projects_root() + if not os.path.isdir(root): + return [] + removed = [] + for name in os.listdir(root): + full = os.path.join(root, name) + if not os.path.isdir(full): + continue + session = read_session(full) + if not session or session.get("dirty"): + continue + source = session.get("source_path") + if not isinstance(source, str) or not source: + continue + # `record_save` and `finish_open` write an absolute path; a relative + # or drive-relative one is a corrupt session, not grounds to delete. + norm = os.path.normpath(source) + drive, _tail = os.path.splitdrive(norm) + if not norm.startswith(drive + os.sep): + continue + if not os.path.exists(norm) and not is_locked(full): + delete_project_dir(full) + removed.append(full) + return removed + + +# --- .json migration --------------------------------------------------------------- + + +def legacy_segment_key(text: str, config: dict) -> str: + """What `dirty.py` stamped before the schema bump: the voice *name*, no + extra inputs, `CACHE_SCHEMA_VERSION` 2.""" + return compute_cache_key(text, config.get("voice"), effective_speed(config), config.get("lang_code", "a"), + config.get("engine_id", "kokoro"), schema_version=2) + + +def migrate_segments(document: Document, project_dir: str, generation_config_for, key_fn, + audio_format: str = "wav") -> dict: + """Rekeys a `.json` project's segments into the project dir (TB6, revised + by the third review). For each clip, a segment whose stored key equals + the legacy expected key and whose file exists is adopted: copied to + `audio/generated/_.` and restamped. Anything else + gets `audio_path = None` and stays dirty; migration never makes a stale + segment look clean. The originals stay where they are. Returns counts.""" + generated = os.path.join(project_dir, *AUDIO_GENERATED.split("/")) + os.makedirs(generated, exist_ok=True) + adopted = dropped = 0 + for clip in document.clips: + if not clip.segments: + continue + text = document.clip_text(clip) + config = generation_config_for(clip) + legacy = legacy_segment_key(text, config) + new_key = key_fn(text, clip) + for segment in clip.segments: + source = segment.audio_path + if segment.cache_key == legacy and source and os.path.isfile(source): + ext = os.path.splitext(source)[1].lstrip(".").lower() or audio_format + target = os.path.join(generated, f"{new_key}_{segment.order_index}.{ext}") + if os.path.realpath(source) != os.path.realpath(target): + shutil.copyfile(source, target) + segment.audio_path = os.path.abspath(target) + segment.cache_key = new_key + adopted += 1 + else: + segment.audio_path = None + dropped += 1 + return {"adopted": adopted, "dropped": dropped} + + +# --- welcome dialog ------------------------------------------------------------------ + + +def project_summary(path: str) -> dict | None: + """What the welcome dialog's details pane shows for a row. A `.tbaw` + answers from `manifest.json` alone (`ZipFile.read`, no extraction, no + `document.json` parse) since this runs on every selection change; a + `.json` entry that hasn't been opened (and so migrated) yet still gets + the raw-JSON reader. `None` when missing or unreadable.""" + if not path or not os.path.isfile(path): + return None + try: + modified = datetime.datetime.fromtimestamp(os.path.getmtime(path)) + except (OSError, ValueError): + return None + if format_for_path(path) == "tbaw": + try: + with zipfile.ZipFile(path) as zf: + manifest = json.loads(zf.read(MANIFEST).decode("utf-8")) + except (OSError, zipfile.BadZipFile, KeyError, json.JSONDecodeError, UnicodeDecodeError): + return None + if not isinstance(manifest, dict): + return None + stats = manifest.get("stats") if isinstance(manifest.get("stats"), dict) else {} + engines = manifest.get("engines") if isinstance(manifest.get("engines"), dict) else {} + stamp = manifest.get("modified") + if isinstance(stamp, str): + try: + modified = datetime.datetime.fromisoformat(stamp) + except ValueError: + pass + return { + "path": os.path.abspath(path), + "modified": modified, + "characters": int(stats.get("characters", 0) or 0), + "clips": int(stats.get("clips", 0) or 0), + "duration_s": float(stats.get("duration_s", 0.0) or 0.0), + "engines": sorted(engines.keys()), + } + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError, ValueError): + return None + if not isinstance(data, dict): + return None + characters = data.get("characters", []) + clips = data.get("clips", []) + return { + "path": os.path.abspath(path), + "modified": modified, + "characters": len(characters) if isinstance(characters, list) else 0, + "clips": len(clips) if isinstance(clips, list) else 0, + "duration_s": None, + "engines": [], + } + + +def new_document_from(previous: Document | None) -> Document: + """WF3: a new project inherits the previous project's characters (a + copy for now - the global library from WF4-WF7 is future work) and one + track per character.""" + import copy + + from kokoro_gui.daw.models import Track + + if previous is None or not previous.characters: + return Document(runs=[], clips=[], tracks=[], characters=[], settings={}) + characters = copy.deepcopy(previous.characters) + tracks = [Track(name=c.name, character_id=c.id, order_index=i) for i, c in enumerate(characters)] + return Document(runs=[], clips=[], tracks=tracks, characters=characters, settings={}) diff --git a/kokoro_gui/qt/schema_form.py b/kokoro_gui/qt/schema_form.py new file mode 100644 index 0000000..9fc54d7 --- /dev/null +++ b/kokoro_gui/qt/schema_form.py @@ -0,0 +1,187 @@ +"""Generic renderer that walks a backend's `list[ConfigField]` +(kokoro_gui/engines/base.py) into Qt form rows. + +This is the concrete payoff of workstream 1 for workstream 3a: the Qt +Generation dock's schema-covered fields (lang_code/voice/speed/split_pattern/ +format/num_threads/caching) are built by walking whatever +`backend.get_config_schema()` returns, not hard-coded per engine. Rebuilding +this widget from a new backend's schema is what makes engine-switching +actually swap the visible fields (see docks/generation_dock.py and app.py's +`switch_engine`). +""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QGroupBox, QHBoxLayout, + QLineEdit, QPushButton, QSpinBox, QVBoxLayout, QWidget, QFileDialog, +) + +from kokoro_gui.engines.base import ConfigField, ConfigFieldType + + +class SchemaFormWidget(QWidget): + """Renders `schema` (a `list[ConfigField]`) as `QFormLayout` rows grouped + by `field.group` into `QGroupBox`es. + + `choices_overrides`: {key: [(label, value), ...]} for fields whose schema + `choices` is `None` because the option set is GUI-resolved rather than + engine data (today: "voice", "lang_code" - see + kokoro_gui/engines/kokoro.py's `get_config_schema` docstring). + + `skip_keys`: field keys to omit entirely - the caller owns a dedicated + widget for them instead (today: "lexicon", rendered as a CRUD list by + docks/lexicon_dock.py rather than a single TEXT field). + + `on_change(key, value)`: called whenever a rendered field's value changes, + so the owning dock can drive autosave the same way Tk's ctk var traces do. + """ + + def __init__( + self, + schema: list[ConfigField], + values: dict[str, Any], + choices_overrides: Optional[dict[str, list[tuple[str, Any]]]] = None, + skip_keys: Optional[set[str]] = None, + on_change: Optional[Callable[[str, Any], None]] = None, + parent: Optional[QWidget] = None, + ): + super().__init__(parent) + self._schema = schema + self._choices_overrides = choices_overrides or {} + self._skip_keys = skip_keys or set() + self._on_change = on_change + self._widgets: dict[str, QWidget] = {} + self._getters: dict[str, Callable[[], Any]] = {} + self._setters: dict[str, Callable[[Any], None]] = {} + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + + groups: dict[str, QFormLayout] = {} + group_order: list[str] = [] + for f in schema: + if f.key in self._skip_keys: + continue + if f.group not in groups: + box = QGroupBox(f.group) + form = QFormLayout() + box.setLayout(form) + groups[f.group] = form + group_order.append(f.group) + outer.addWidget(box) + self._add_field(groups[f.group], f) + + outer.addStretch(1) + self.set_values(values) + + def _add_field(self, form: QFormLayout, f: ConfigField) -> None: + choices = self._choices_overrides.get(f.key, f.choices) + + if f.type in (ConfigFieldType.CHOICE,) or choices is not None: + combo = QComboBox() + for label, value in (choices or []): + combo.addItem(str(label), value) + combo.currentIndexChanged.connect(lambda _i, k=f.key: self._emit_change(k)) + form.addRow(f.label, combo) + self._widgets[f.key] = combo + self._getters[f.key] = lambda c=combo: c.currentData() + self._setters[f.key] = lambda v, c=combo: self._set_combo(c, v) + + elif f.type == ConfigFieldType.BOOL: + box = QCheckBox() + box.toggled.connect(lambda _v, k=f.key: self._emit_change(k)) + form.addRow(f.label, box) + self._widgets[f.key] = box + self._getters[f.key] = box.isChecked + self._setters[f.key] = box.setChecked + + elif f.type == ConfigFieldType.INT: + spin = QSpinBox() + spin.setRange(int(f.min if f.min is not None else 0), int(f.max if f.max is not None else 100)) + spin.setSingleStep(int(f.step or 1)) + spin.valueChanged.connect(lambda _v, k=f.key: self._emit_change(k)) + form.addRow(f.label, spin) + self._widgets[f.key] = spin + self._getters[f.key] = spin.value + self._setters[f.key] = spin.setValue + + elif f.type in (ConfigFieldType.FLOAT, ConfigFieldType.SLIDER): + spin = QDoubleSpinBox() + spin.setRange(float(f.min if f.min is not None else 0.0), float(f.max if f.max is not None else 1.0)) + spin.setSingleStep(float(f.step or 0.1)) + spin.setDecimals(3) + spin.valueChanged.connect(lambda _v, k=f.key: self._emit_change(k)) + form.addRow(f.label, spin) + self._widgets[f.key] = spin + self._getters[f.key] = spin.value + self._setters[f.key] = spin.setValue + + elif f.type == ConfigFieldType.FILE: + row = QWidget() + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 0, 0, 0) + edit = QLineEdit() + browse = QPushButton("Browse...") + + def _browse(_checked=False, e=edit): + path, _ = QFileDialog.getOpenFileName(self, "Select file") + if path: + e.setText(path) + + browse.clicked.connect(_browse) + layout.addWidget(edit) + layout.addWidget(browse) + edit.textChanged.connect(lambda _v, k=f.key: self._emit_change(k)) + form.addRow(f.label, row) + self._widgets[f.key] = row + self._getters[f.key] = edit.text + self._setters[f.key] = edit.setText + + else: # TEXT, or any future type - plain line edit fallback + edit = QLineEdit() + edit.textChanged.connect(lambda _v, k=f.key: self._emit_change(k)) + form.addRow(f.label, edit) + self._widgets[f.key] = edit + self._getters[f.key] = edit.text + self._setters[f.key] = edit.setText + + @staticmethod + def _set_combo(combo: QComboBox, value: Any) -> None: + idx = combo.findData(value) + if idx < 0 and combo.count() > 0: + idx = 0 + if idx >= 0: + combo.setCurrentIndex(idx) + + def _emit_change(self, key: str) -> None: + if self._on_change is not None and key in self._getters: + self._on_change(key, self._getters[key]()) + + def widget_for(self, key: str) -> Optional[QWidget]: + return self._widgets.get(key) + + def values(self) -> dict[str, Any]: + return {k: getter() for k, getter in self._getters.items()} + + def set_values(self, values: dict[str, Any]) -> None: + for k, setter in self._setters.items(): + if k in values: + setter(values[k]) + + def set_choices(self, key: str, choices: list[tuple[str, Any]], current: Any = None) -> None: + """Repopulate a CHOICE field's options at runtime (used for "voice" + when the active language or backend changes).""" + combo = self._widgets.get(key) + if not isinstance(combo, QComboBox): + return + combo.blockSignals(True) + combo.clear() + for label, value in choices: + combo.addItem(str(label), value) + combo.blockSignals(False) + if current is not None: + self._set_combo(combo, current) + elif combo.count() > 0: + combo.setCurrentIndex(0) diff --git a/kokoro_gui/qt/selection.py b/kokoro_gui/qt/selection.py new file mode 100644 index 0000000..1f47046 --- /dev/null +++ b/kokoro_gui/qt/selection.py @@ -0,0 +1,82 @@ +"""SelectionModel: item 1 ("Sync layer") of the DAW-for-text redesign's +remaining-work roadmap. One canonical, app-wide notion of "what's currently +selected" - a clip, a character (via a timeline lane label), a plain text +range, or nothing - shared between `TranscriptEditor` and `TimelineView` so +clicking either side selects the same thing on both. + +`changed` is bare (no-arg), matching `EngineSignalBridge.finished`'s existing +style (kokoro_gui/qt/signals.py): receivers read the model's current state +off its attributes rather than the signal carrying a payload, which keeps +every consumer's connect() the same regardless of which field actually +changed. + +State is intentionally flat and mutually exclusive rather than a tagged +union type - `kind` is a computed discriminator over the three optional +fields so consumers never have to reconstruct "which one is set" themselves. +""" +from __future__ import annotations + +from typing import Optional + +from PySide6.QtCore import QObject, Signal + + +class SelectionModel(QObject): + """Mutators are idempotent (setting the same value twice does not + re-emit `changed`) and mutually exclusive (selecting one of clip/ + character/range clears the other two fields).""" + + changed = Signal() + # UI4: the clip the transport is currently inside. Separate from, and + # non-exclusive with, the user's selection - playback must never clobber + # what they have selected. Its own signal so `changed` consumers (the + # Settings/FX tabs) don't re-render 30 times a second. + playingChanged = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.selected_clip_id: Optional[str] = None + self.selected_character_id: Optional[str] = None + self.selected_range: Optional[tuple[int, int]] = None + self.playing_clip_id: Optional[str] = None + + @property + def kind(self) -> str: + if self.selected_clip_id is not None: + return "clip" + if self.selected_character_id is not None: + return "character" + if self.selected_range is not None: + return "range" + return "none" + + def _set(self, clip_id: Optional[str], character_id: Optional[str], + selected_range: Optional[tuple[int, int]]) -> None: + if (clip_id, character_id, selected_range) == ( + self.selected_clip_id, self.selected_character_id, self.selected_range + ): + return + self.selected_clip_id = clip_id + self.selected_character_id = character_id + self.selected_range = selected_range + self.changed.emit() + + def select_clip(self, clip_id: str) -> None: + self._set(clip_id, None, None) + + def select_character(self, character_id: str) -> None: + self._set(None, character_id, None) + + def select_range(self, start: int, end: int) -> None: + if end <= start: + raise ValueError(f"select_range requires end > start, got start={start}, end={end}") + self._set(None, None, (start, end)) + + def clear(self) -> None: + self._set(None, None, None) + + def set_playing_clip(self, clip_id: Optional[str]) -> None: + if clip_id == self.playing_clip_id: + return + self.playing_clip_id = clip_id + self.playingChanged.emit() diff --git a/kokoro_gui/qt/settings.py b/kokoro_gui/qt/settings.py new file mode 100644 index 0000000..982ab11 --- /dev/null +++ b/kokoro_gui/qt/settings.py @@ -0,0 +1,48 @@ +"""Load/save `config_qt.json` (the Qt frontend's app-settings file), plus +the base64 helpers `kokoro_gui.qt.workspace` uses for `QMainWindow` +dock-layout bytes. + +Pure functions (no `QMainWindow`/app-instance state held here) so they're +easy to unit test in isolation - `app.py` calls these and owns the debounce +timer (`QTimer.singleShot`). +""" +from __future__ import annotations + +import base64 +import copy +import json +import os + +from kokoro_gui.qt import spec + + +def load_settings(config_file: str) -> dict: + # deepcopy, not dict(...): SETTINGS_DEFAULTS["lexicon"] is a mutable {} + # shared across every call - a shallow copy would let one instance's + # in-place `settings["lexicon"][k] = v` (lexicon_dock.py's add_rule) + # leak into every other instance/test that reads the same defaults. + defaults = copy.deepcopy(spec.SETTINGS_DEFAULTS) + if os.path.exists(config_file): + try: + with open(config_file, "r", encoding="utf-8") as f: + return {**defaults, **json.load(f)} + except Exception: + pass + return defaults + + +def save_settings(config_file: str, settings: dict) -> None: + try: + with open(config_file, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=4) + except Exception as e: + print(f"Failed to save Qt settings: {e}") + + +def encode_bytes(qbytearray) -> str: + return base64.b64encode(bytes(qbytearray)).decode("ascii") + + +def decode_bytes(b64_str: str): + from PySide6.QtCore import QByteArray + return QByteArray(base64.b64decode(b64_str.encode("ascii"))) diff --git a/kokoro_gui/qt/signals.py b/kokoro_gui/qt/signals.py new file mode 100644 index 0000000..caa5541 --- /dev/null +++ b/kokoro_gui/qt/signals.py @@ -0,0 +1,43 @@ +"""Cross-thread callback marshalling for the Qt frontend. + +`KokoroEngine` calls `self.on_status`/`self.on_progress`/`self.on_finish` from +its own background `AsyncLoopThread` (see kokoro_engine.py's `AsyncLoopThread` +and kokoro_gui/engine/conversion.py's call sites). Qt's answer to that is a +`QObject` living on the main thread whose signals are emitted from the worker +thread: PySide6 detects the emitting thread differs from the receiving +QObject's thread and automatically queues the connected slot call onto that +thread's event loop (a `Qt.QueuedConnection`), with no `after()`-style +boilerplate needed - this works for any Python thread that emits into a +QObject with a running event loop, not just a `QThread`. + +The same trick applies to the `concurrent.futures.Future.add_done_callback(...)` +pattern `preview_conversion`/mixing use: any Qt widget/dock is itself a +`QObject` that was constructed on the main thread, so defining a small +`Signal` directly on that dock/window class and emitting it from inside the +done-callback (which runs on the worker thread) is enough - no dedicated +bridge object needed for those one-off cases. See docks/mixing_dock.py's +`previewFinished`/`mixFinished` and app.py's `previewFinished` for examples. +""" +from PySide6.QtCore import QObject, Signal + + +class EngineSignalBridge(QObject): + """One instance per active engine. Reconnected (not reused) across + `switch_engine` calls so a stale engine's callbacks never emit into a + dock the app has already rebuilt for a different backend.""" + + # func(msg: str, is_error: bool) - kokoro_engine.py:72 + status = Signal(str, bool) + # func(percentage: float, time_elapsed: float, eta: str, detail_text: str) - kokoro_engine.py:73 + progress = Signal(float, float, str, str) + # func() - kokoro_engine.py:74 + finished = Signal() + + +def wire_engine(engine, bridge: EngineSignalBridge) -> None: + """Point `engine`'s callback attributes at `bridge`'s signals - the Qt + equivalent of a plain `engine.on_progress = self.on_engine_progress` + wiring, just emitting a signal instead of calling a bound method directly.""" + engine.on_status = bridge.status.emit + engine.on_progress = bridge.progress.emit + engine.on_finish = bridge.finished.emit diff --git a/kokoro_gui/qt/spec.py b/kokoro_gui/qt/spec.py new file mode 100644 index 0000000..8a30201 --- /dev/null +++ b/kokoro_gui/qt/spec.py @@ -0,0 +1,231 @@ +"""Pure-data constants for the Qt frontend's field lists (generation config +keys, FX preset keys/slider specs, language/voice tables, settings defaults). + +This module has no Qt imports so both `kokoro_gui/qt/*` and the test suite can +import it standalone. + +These constants originated as a mirror of the now-retired Tk frontend's +(`gui.py`, `kokoro_gui/ui/*.py`) hard-coded field lists, kept in sync via +`tests/gui_qt/test_qt_config_assembly.py`'s cross-frontend check during the +migration (see PLAN_qt_and_engine_abstraction.md, workstream 3a). Now that Tk +has been removed, this module is simply the canonical source of truth for the +Qt frontend. + +- FX_FIELD_SPECS has no widget for seven FX_PRESET_KEYS fields + (reverb_dry_level, chorus_mix, phaser_depth, phaser_mix, comp_attack, + comp_release, limiter_release) — a pre-existing gap inherited from Tk, not + yet closed (see ROADMAP.md). +""" +from dataclasses import dataclass +from typing import Optional + +# --- Generation config dict (non-FX keys) -------------------------------- + +GENERATION_BASE_KEYS = [ + "engine_id", "lang_code", "voice", "speed", "split_pattern", "filename", + "format", "out_dir", "separate", "combine", "export_subtitles", "caching", + "time_id", "num_threads", "volume", "pitch", "normalize", "trim_silence", + "lexicon", +] + +# --- FX preset / config-merge keys ---------------------------------------- + +FX_PRESET_KEYS = [ + "reverb_enabled", "reverb_room_size", "reverb_wet_level", "reverb_damping", + "reverb_dry_level", "reverb_width", + "eq_bass", "eq_treble", + "comp_enabled", "comp_threshold", "comp_ratio", "comp_attack", "comp_release", + "distortion_enabled", "distortion_drive", + "chorus_enabled", "chorus_rate", "chorus_depth", "chorus_mix", + "phaser_enabled", "phaser_rate", "phaser_depth", "phaser_mix", + "clipping_enabled", "clipping_thresh", + "bitcrush_enabled", "bitcrush_depth", + "gsm_enabled", + "highpass_enabled", "highpass_freq", + "lowpass_enabled", "lowpass_freq", + "delay_enabled", "delay_time", "delay_feedback", "delay_mix", + "pitch_shift_enabled", "pitch_shift_semitones", + "limiter_enabled", "limiter_threshold", "limiter_release", + "gain_enabled", "gain_db", +] + + +@dataclass(frozen=True) +class FXSliderSpec: + """One numeric FX field the UI exposes a control for. `steps` mirrors the + Tk `CTkSlider(number_of_steps=...)` value so `(maximum - minimum) / steps` + reproduces the same granularity in a QDoubleSpinBox's singleStep.""" + key: str + label: str + minimum: float + maximum: float + steps: int + group: str # dock section heading, e.g. "Spatial & Time" + section: str # sub-heading, e.g. "Reverb" + enabled_key: Optional[str] = None # bool field this is gated under, if any + unit: str = "" + decimals: int = 2 + + +FX_FIELD_SPECS = [ + # --- Dynamics --- + FXSliderSpec("comp_threshold", "Threshold", -60, 0, 60, "Dynamics", "Compressor", "comp_enabled", "dB", 1), + FXSliderSpec("comp_ratio", "Ratio", 1, 20, 19, "Dynamics", "Compressor", "comp_enabled", ":1", 1), + FXSliderSpec("limiter_threshold", "Threshold", -12, 0, 24, "Dynamics", "Limiter", "limiter_enabled", "dB", 1), + FXSliderSpec("gain_db", "dB", -20, 20, 80, "Dynamics", "Gain", "gain_enabled", "dB", 1), + # --- EQ & Filters --- + FXSliderSpec("eq_bass", "Bass (LowShelf)", -20, 20, 40, "EQ & Filters", "EQ", None, "dB", 1), + FXSliderSpec("eq_treble", "Treble (HighShelf)", -20, 20, 40, "EQ & Filters", "EQ", None, "dB", 1), + FXSliderSpec("highpass_freq", "Freq", 20, 1000, 100, "EQ & Filters", "HighPass Filter", "highpass_enabled", "Hz", 0), + FXSliderSpec("lowpass_freq", "Freq", 1000, 20000, 100, "EQ & Filters", "LowPass Filter", "lowpass_enabled", "Hz", 0), + # --- Spatial & Time --- + FXSliderSpec("reverb_room_size", "Room Size", 0, 1, 100, "Spatial & Time", "Reverb", "reverb_enabled", "", 2), + FXSliderSpec("reverb_wet_level", "Wet Level", 0, 1, 100, "Spatial & Time", "Reverb", "reverb_enabled", "", 2), + FXSliderSpec("reverb_damping", "Damping", 0, 1, 100, "Spatial & Time", "Reverb", "reverb_enabled", "", 2), + FXSliderSpec("reverb_width", "Width", 0, 1, 100, "Spatial & Time", "Reverb", "reverb_enabled", "", 2), + FXSliderSpec("delay_time", "Time", 0, 2, 100, "Spatial & Time", "Delay", "delay_enabled", "s", 2), + FXSliderSpec("delay_feedback", "Feedback", 0, 1, 100, "Spatial & Time", "Delay", "delay_enabled", "", 2), + FXSliderSpec("delay_mix", "Mix", 0, 1, 100, "Spatial & Time", "Delay", "delay_enabled", "", 2), + # --- Guitar / Modulation --- + FXSliderSpec("chorus_rate", "Rate", 0.1, 10, 50, "Guitar / Modulation", "Chorus", "chorus_enabled", "Hz", 1), + FXSliderSpec("chorus_depth", "Depth", 0, 1, 50, "Guitar / Modulation", "Chorus", "chorus_enabled", "", 2), + FXSliderSpec("distortion_drive", "Drive", 0, 60, 60, "Guitar / Modulation", "Distortion", "distortion_enabled", "dB", 1), + FXSliderSpec("phaser_rate", "Rate", 0.1, 10, 50, "Guitar / Modulation", "Phaser", "phaser_enabled", "Hz", 1), + FXSliderSpec("clipping_thresh", "Threshold", -20, 0, 40, "Guitar / Modulation", "Clipping", "clipping_enabled", "dB", 1), + # --- Quality / Pitch --- + FXSliderSpec("pitch_shift_semitones", "Semitones", -12, 12, 48, "Quality / Pitch", "Pitch Shift (High Quality)", "pitch_shift_enabled", "st", 1), + FXSliderSpec("bitcrush_depth", "Bit Depth", 2, 16, 28, "Quality / Pitch", "Bitcrush", "bitcrush_enabled", "", 1), +] + +# Standalone checkbox with no slider at all (Quality / Pitch group). +FX_STANDALONE_TOGGLES = [ + ("gsm_enabled", "GSM Compressor (Phone Quality)", "Quality / Pitch"), +] + +# FX_PRESET_KEYS entries with no matching widget in either frontend today +# (see module docstring) - still valid dict keys, just not user-editable. +FX_KEYS_WITHOUT_WIDGET = {"reverb_dry_level", "chorus_mix", "phaser_depth", "phaser_mix", "comp_attack", "comp_release", "limiter_release"} + +FX_GROUP_ORDER = ["Dynamics", "EQ & Filters", "Spatial & Time", "Guitar / Modulation", "Quality / Pitch"] + +# --- Voice / language display data ---------------------------------------- + +LANGUAGES = { + "American English": "a", + "British English": "b", + "Spanish": "e", + "French": "f", + "Italian": "i", + "Portuguese": "p", + "Japanese": "j", + "Chinese": "z", +} + +VOICE_DB = { + "a": ["af_heart", "af_alloy", "af_aoede", "af_bella", "af_jessica", "af_kore", "af_nicole", "af_nova", "af_river", "af_sarah", "af_sky", "am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam", "am_michael", "am_onyx", "am_puck", "am_santa"], + "b": ["bf_alice", "bf_emma", "bf_isabella", "bf_lily", "bm_daniel", "bm_fable", "bm_george", "bm_lewis"], + "e": ["ef_dora", "em_alex", "em_santa"], + "f": ["ff_siwis"], + "i": ["if_sara", "im_nicola"], + "p": ["pf_dora", "pm_alex"], + "j": ["jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro"], + "z": ["zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zm_yunjian"], +} + +MIX_PREVIEW_TEXT = { + "f": "Ceci est un aperçu de votre voix personnalisée.", + "e": "Esta es una vista previa de su voz personalizada.", + "i": "Questa è un'anteprima della tua voce personalizzata.", + "p": "Esta é uma prévia da sua voz personalizada.", + "j": "これはカスタム合成音声のプレビューです。", + "z": "这是您的自定义混合语音预览。", +} +MIX_PREVIEW_TEXT_DEFAULT = "This is a preview of your custom mixed voice." + +# --- App-settings defaults (config_qt.json) -------------------------------- + +SETTINGS_DEFAULTS = { + "lang_code": "a", + "voice": "af_heart", + "filename": "output", + "format": "wav", + "out_dir": "audio_output", + "speed": 1.0, + "volume": 1.0, + "pitch": 0.0, + "num_threads": 1, + "split_pattern": r"\n+", + "separate": True, + "combine": True, + "export_subtitles": False, + "caching": True, + "jit_enabled": False, + "auto_split_by_paragraph": False, + "character_fx_paste_splits": True, + "character_fx_copy": True, + "theme": "dark", # Options > Theme: "light" | "dark" + "device": "auto", # Options > Device: "auto" | "cpu" | "cuda" + "last_project": None, # File menu: the project launch reopens + "recent_projects": [], # File > Recent, most recent first (max 10) + "show_welcome": True, # Welcome dialog on launch (File > Welcome... reopens it) + "normalize": False, + "trim": False, + "apply_fx": True, + "reverb_enabled": False, + "reverb_room_size": 0.5, + "reverb_wet_level": 0.3, + "reverb_damping": 0.5, + "reverb_dry_level": 1.0, + "reverb_width": 1.0, + "eq_bass": 0.0, + "eq_treble": 0.0, + "comp_enabled": False, + "comp_threshold": -20.0, + "comp_ratio": 4.0, + "comp_attack": 1.0, + "comp_release": 100.0, + "distortion_enabled": False, + "distortion_drive": 25.0, + "chorus_enabled": False, + "chorus_rate": 1.0, + "chorus_depth": 0.25, + "chorus_mix": 0.5, + "phaser_enabled": False, + "phaser_rate": 1.0, + "phaser_depth": 0.5, + "phaser_mix": 0.5, + "clipping_enabled": False, + "clipping_thresh": -6.0, + "bitcrush_enabled": False, + "bitcrush_depth": 8.0, + "gsm_enabled": False, + "highpass_enabled": False, + "highpass_freq": 50.0, + "lowpass_enabled": False, + "lowpass_freq": 10000.0, + "delay_enabled": False, + "delay_time": 0.5, + "delay_feedback": 0.0, + "delay_mix": 0.5, + "pitch_shift_enabled": False, + "pitch_shift_semitones": 0.0, + "limiter_enabled": False, + "limiter_threshold": -1.0, + "limiter_release": 100.0, + "gain_enabled": False, + "gain_db": 0.0, + "engine_id": "kokoro", + "asr_engine": "audio8", + "lexicon": {}, + # Workspace layouts: {"Advanced": {"state": b64, "geometry": b64}, ...} + # (kokoro_gui/qt/workspace.py). The old flat dock_state/geometry keys + # migrate into workspaces.Advanced on first load. + "workspaces": {}, + "active_workspace": "Advanced", +} + +_FX_ENABLED_KEYS = {s.enabled_key for s in FX_FIELD_SPECS if s.enabled_key} +assert set(FX_PRESET_KEYS) == ( + {s.key for s in FX_FIELD_SPECS} | FX_KEYS_WITHOUT_WIDGET + | {t[0] for t in FX_STANDALONE_TOGGLES} | _FX_ENABLED_KEYS +) diff --git a/kokoro_gui/qt/theme.py b/kokoro_gui/qt/theme.py new file mode 100644 index 0000000..6848a85 --- /dev/null +++ b/kokoro_gui/qt/theme.py @@ -0,0 +1,380 @@ +"""Light/dark palettes for the Qt shell (UI10 of +Claude/PLAN_ui_shell_redesign.md). + +One `Palette` dataclass of named color tokens, two instances (`LIGHT`, +`DARK`). Custom-painted widgets (the transcript gutter, the timeline lanes +and ruler, the playhead) read `current()` at paint time instead of holding +hard-coded hex constants, so a theme switch only needs a repaint. + +`apply(qapp, name)` sets the `QApplication` style (Fusion, for both themes, +since the native Windows style ignores most palette roles), a `QPalette` +built from the tokens, the application font (`FONT_FAMILIES`, +`FONT_POINT_SIZE`) and the stylesheet `stylesheet(pal)` renders from the +same tokens (flat dock titles, borderless group boxes, rounded inputs and +buttons, underlined tabs, thin scrollbars, a flat progress bar). Widgets +opt into the two button variants with dynamic properties: +`setProperty("primary", True)` for the one filled accent button in a row, +`setProperty("transport", True)` for the round play/pause/stop buttons. +`set_active(name)` alone is enough for tests and for headless code that +only needs the token values. + +Only `PySide6.QtGui`/`QtWidgets` are imported inside `apply`, so importing +this module for its token values doesn't need a QApplication. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass + +# Chevrons for combo/spin boxes and the checkbox tick, one file per theme +# where the stroke color differs. QSS takes `image: url()` only, so +# these are files on disk rather than inline data. +ASSETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets") + +THEME_NAMES = ("light", "dark") +DEFAULT_THEME = "dark" + +# First installed family wins (Qt resolves the list itself); the last two +# are on every Linux box, Segoe UI on every Windows one. +FONT_FAMILIES = ("Segoe UI", "Inter", "SF Pro Text", "Helvetica Neue", "Ubuntu", "Noto Sans", "DejaVu Sans") +FONT_POINT_SIZE = 10 +# The transcript is read for minutes at a time; one point over the UI font. +EDITOR_FONT_POINT_SIZE = FONT_POINT_SIZE + 1 + + +@dataclass(frozen=True) +class Palette: + name: str + window: str + panel: str + panel_alt: str + text: str + text_muted: str + gutter_bg: str + gutter_text: str + lane_bg: str + lane_alt_bg: str + lane_border: str + ruler_bg: str + ruler_text: str + playhead: str + selection_border: str + dirty_underline: str + split_rule: str + fx_badge_bg: str + fx_badge_text: str + playing_highlight: str + estimated_outline: str + accent: str + accent_hover: str + border: str + hover: str + + +# Three surface levels per theme: `window` (dock area, menus, ruler, +# gutter), `panel` (editor, lists, timeline lanes) and `panel_alt` (dock +# titles, buttons, track headers). Everything else is a line or a text +# color on one of those. +LIGHT = Palette( + name="light", + window="#f4f4f5", + panel="#ffffff", + panel_alt="#e9e9eb", + text="#1c1c1e", + text_muted="#6b6f76", + gutter_bg="#f4f4f5", + gutter_text="#55595f", + lane_bg="#ffffff", + lane_alt_bg="#f7f7f8", + lane_border="#d4d4d8", + ruler_bg="#ececee", + ruler_text="#55595f", + playhead="#e5484d", + selection_border="#1c1c1e", + dirty_underline="#e5484d", + split_rule="#a1a1aa", + fx_badge_bg="#3f3f46", + fx_badge_text="#ffffff", + playing_highlight="#f5d87a", + estimated_outline="#8a8f98", + accent="#2563eb", + accent_hover="#1d4fd8", + border="#d4d4d8", + hover="#dedee1", +) + +DARK = Palette( + name="dark", + window="#1e1f22", + panel="#26282b", + panel_alt="#2f3136", + text="#e4e5e7", + text_muted="#8f939a", + gutter_bg="#1e1f22", + gutter_text="#b0b3b8", + lane_bg="#26282b", + lane_alt_bg="#222427", + lane_border="#3a3d42", + ruler_bg="#1e1f22", + ruler_text="#b0b3b8", + playhead="#f0716a", + selection_border="#f5f5f5", + dirty_underline="#f0716a", + split_rule="#5a5e66", + fx_badge_bg="#111214", + fx_badge_text="#f0f0f0", + playing_highlight="#8a7a2a", + estimated_outline="#8f939a", + accent="#4c8df6", + accent_hover="#6ba1f8", + border="#3a3d42", + hover="#383b41", +) + +_PALETTES = {"light": LIGHT, "dark": DARK} +_active: Palette = LIGHT + + +def palette_for(name: str) -> Palette: + return _PALETTES.get(name, LIGHT) + + +def set_active(name: str) -> Palette: + """Makes `name` the palette `current()` returns. Unknown names fall back + to light rather than raising, same tolerance the rest of the settings + reads have for a hand-edited config_qt.json.""" + global _active + _active = palette_for(name) + return _active + + +def current() -> Palette: + return _active + + +def _rgba(hex_color: str, alpha: float) -> str: + """`#rrggbb` -> `rgba(r, g, b, a)` for the few QSS rules that need a + translucent token (the progress chunk under its own status text).""" + h = hex_color.lstrip("#") + r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4)) + return f"rgba({r}, {g}, {b}, {alpha:.2f})" + + +def stylesheet(pal: Palette) -> str: + """The application stylesheet for `pal`. Only tokens from the palette + go in, so a theme switch is `qapp.setStyleSheet(stylesheet(pal))` and + nothing else.""" + on_accent = "#ffffff" + chunk = _rgba(pal.accent, 0.45) + + def asset(name: str) -> str: + return os.path.join(ASSETS_DIR, name).replace("\\", "/") + + down = asset(f"chevron_down_{pal.name}.svg") + up = asset(f"chevron_up_{pal.name}.svg") + check = asset("check.svg") + return f""" +QToolTip {{ + background: {pal.panel_alt}; color: {pal.text}; + border: 1px solid {pal.border}; padding: 4px 6px; +}} +QMainWindow::separator {{ background: {pal.window}; width: 4px; height: 4px; }} +QMainWindow::separator:hover {{ background: {pal.accent}; }} + +QDockWidget::title {{ + background: {pal.panel_alt}; color: {pal.text_muted}; + padding: 4px 8px; text-align: left; +}} +QDockWidget::close-button, QDockWidget::float-button {{ + border: none; background: transparent; padding: 0; icon-size: 10px; +}} +QDockWidget::close-button:hover, QDockWidget::float-button:hover {{ background: {pal.hover}; border-radius: 3px; }} + +QGroupBox {{ + border: none; margin-top: 16px; padding: 0; + font-weight: 600; color: {pal.text_muted}; +}} +QGroupBox::title {{ subcontrol-origin: margin; subcontrol-position: top left; left: 0; padding: 0; }} +QGroupBox QWidget {{ font-weight: normal; color: {pal.text}; }} + +QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QTextEdit, QPlainTextEdit, +QListWidget, QListView, QTreeView, QTableView {{ + background: {pal.panel}; color: {pal.text}; + border: 1px solid {pal.border}; border-radius: 4px; + selection-background-color: {pal.accent}; selection-color: {on_accent}; +}} +QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {{ padding: 2px 6px; min-height: 20px; }} +QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus, +QTextEdit:focus, QPlainTextEdit:focus {{ border-color: {pal.accent}; }} +QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled {{ + color: {pal.text_muted}; background: {pal.window}; +}} +QComboBox::drop-down {{ border: none; width: 20px; subcontrol-origin: padding; subcontrol-position: right center; }} +QComboBox::down-arrow {{ image: url({down}); width: 10px; height: 10px; }} +QComboBox QAbstractItemView {{ + background: {pal.panel}; border: 1px solid {pal.border}; + selection-background-color: {pal.accent}; selection-color: {on_accent}; outline: none; +}} +QSpinBox::up-button, QDoubleSpinBox::up-button, QSpinBox::down-button, QDoubleSpinBox::down-button {{ + border: none; width: 16px; background: transparent; +}} +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow {{ image: url({up}); width: 8px; height: 8px; }} +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow {{ image: url({down}); width: 8px; height: 8px; }} + +QCheckBox {{ spacing: 6px; }} +QCheckBox::indicator, QGroupBox::indicator {{ + width: 14px; height: 14px; border-radius: 3px; + border: 1px solid {pal.border}; background: {pal.panel}; +}} +QCheckBox::indicator:hover {{ border-color: {pal.text_muted}; }} +QCheckBox::indicator:checked, QGroupBox::indicator:checked {{ + background: {pal.accent}; border-color: {pal.accent}; image: url({check}); +}} +QCheckBox::indicator:disabled {{ background: {pal.window}; }} +QCheckBox::indicator:checked:disabled {{ background: {pal.text_muted}; border-color: {pal.text_muted}; }} +QMenu::indicator {{ width: 14px; height: 14px; }} + +QPushButton, QToolButton {{ + background: {pal.panel_alt}; color: {pal.text}; + border: 1px solid {pal.border}; border-radius: 4px; + padding: 3px 12px; min-height: 20px; +}} +QToolButton {{ padding: 3px 8px; }} +QPushButton:hover, QToolButton:hover {{ background: {pal.hover}; }} +QPushButton:pressed, QToolButton:pressed {{ background: {pal.border}; }} +QPushButton:checked, QToolButton:checked {{ background: {pal.accent}; color: {on_accent}; border-color: {pal.accent}; }} +QPushButton:disabled, QToolButton:disabled {{ color: {pal.text_muted}; background: {pal.window}; border-color: {pal.border}; }} +QPushButton:flat, QToolButton[autoRaise="true"] {{ background: transparent; border-color: transparent; }} +QPushButton:flat:hover, QToolButton[autoRaise="true"]:hover {{ background: {pal.hover}; }} +QToolButton::menu-button {{ border: none; border-left: 1px solid {pal.border}; width: 16px; }} +QToolButton::menu-arrow {{ image: url({down}); width: 10px; height: 10px; }} +QToolButton[primary="true"]::menu-arrow {{ image: url({asset("chevron_down_on_accent.svg")}); }} + +QPushButton[primary="true"], QToolButton[primary="true"] {{ + background: {pal.accent}; color: {on_accent}; border-color: {pal.accent}; font-weight: 600; +}} +QPushButton[primary="true"]:hover, QToolButton[primary="true"]:hover {{ background: {pal.accent_hover}; border-color: {pal.accent_hover}; }} +QPushButton[primary="true"]:disabled, QToolButton[primary="true"]:disabled {{ + background: {pal.panel_alt}; color: {pal.text_muted}; border-color: {pal.border}; +}} +QToolButton[primary="true"]::menu-button {{ border-left-color: {_rgba("#ffffff", 0.35)}; }} +QToolButton[primary="true"]:disabled::menu-button {{ border-left-color: {pal.border}; }} + +QToolButton[transport="true"] {{ + min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px; + padding: 0; border-radius: 15px; +}} + +QTabBar::tab {{ + background: transparent; color: {pal.text_muted}; + border: none; padding: 5px 12px; margin: 0; +}} +QTabBar::tab:top {{ border-bottom: 2px solid transparent; }} +QTabBar::tab:bottom {{ border-top: 2px solid transparent; }} +QTabBar::tab:hover {{ color: {pal.text}; }} +QTabBar::tab:selected {{ color: {pal.text}; }} +QTabBar::tab:top:selected {{ border-bottom-color: {pal.accent}; }} +QTabBar::tab:bottom:selected {{ border-top-color: {pal.accent}; }} +QTabWidget::pane {{ border: none; border-top: 1px solid {pal.border}; }} + +QProgressBar {{ + background: {pal.panel_alt}; color: {pal.text}; + border: none; border-radius: 4px; text-align: center; + min-height: 20px; max-height: 20px; +}} +QProgressBar::chunk {{ background: {chunk}; border-radius: 4px; }} + +QSlider::groove:horizontal {{ height: 4px; background: {pal.border}; border-radius: 2px; }} +QSlider::sub-page:horizontal {{ background: {pal.accent}; border-radius: 2px; }} +QSlider::handle:horizontal {{ + width: 14px; height: 14px; margin: -5px 0; border-radius: 7px; + background: {pal.text}; border: none; +}} +QSlider::handle:horizontal:hover {{ background: {pal.accent}; }} +QSlider::groove:vertical {{ width: 4px; background: {pal.border}; border-radius: 2px; }} +QSlider::add-page:vertical {{ background: {pal.accent}; border-radius: 2px; }} +QSlider::handle:vertical {{ + width: 14px; height: 14px; margin: 0 -5px; border-radius: 7px; + background: {pal.text}; border: none; +}} + +QMenuBar {{ background: {pal.window}; padding: 2px 4px; }} +QMenuBar::item {{ padding: 4px 8px; border-radius: 4px; background: transparent; }} +QMenuBar::item:selected {{ background: {pal.hover}; }} +QMenu {{ background: {pal.panel}; border: 1px solid {pal.border}; padding: 4px; }} +QMenu::item {{ padding: 4px 24px 4px 10px; border-radius: 3px; }} +QMenu::item:selected {{ background: {pal.accent}; color: {on_accent}; }} +QMenu::item:disabled {{ color: {pal.text_muted}; }} +QMenu::separator {{ height: 1px; background: {pal.border}; margin: 4px 6px; }} + +QScrollBar:vertical {{ background: transparent; width: 10px; margin: 0; }} +QScrollBar:horizontal {{ background: transparent; height: 10px; margin: 0; }} +QScrollBar::handle:vertical {{ background: {pal.border}; border-radius: 3px; min-height: 24px; margin: 2px; }} +QScrollBar::handle:horizontal {{ background: {pal.border}; border-radius: 3px; min-width: 24px; margin: 2px; }} +QScrollBar::handle:hover {{ background: {pal.text_muted}; }} +QScrollBar::add-line, QScrollBar::sub-line {{ width: 0; height: 0; }} +QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }} + +QScrollArea {{ border: none; background: transparent; }} +QScrollArea > QWidget > QWidget {{ background: transparent; }} +QHeaderView::section {{ + background: {pal.panel_alt}; color: {pal.text_muted}; + border: none; border-bottom: 1px solid {pal.border}; padding: 3px 6px; +}} +QSplitter::handle {{ background: {pal.window}; }} +QStatusBar {{ background: {pal.window}; }} +""" + + +def apply(qapp, name: str) -> Palette: + """Applies `name` to the running QApplication: style, QPalette, font + and stylesheet. Returns the palette so the caller can emit its own + theme-changed signal.""" + from PySide6.QtGui import QColor, QFont, QPalette + from PySide6.QtWidgets import QStyleFactory + + pal = set_active(name) + if qapp is None: + return pal + + # Fusion for both themes: it honors every QPalette role on every + # platform (the native Windows style ignores most of them, and paints + # black boxes under the offscreen platform the screenshot script and + # the test suite use), so light and dark render through one code path. + qapp.setStyle(QStyleFactory.create("Fusion")) + qpal = QPalette() + roles = { + QPalette.ColorRole.Window: pal.window, + QPalette.ColorRole.WindowText: pal.text, + QPalette.ColorRole.Base: pal.panel, + QPalette.ColorRole.AlternateBase: pal.panel_alt, + QPalette.ColorRole.ToolTipBase: pal.panel_alt, + QPalette.ColorRole.ToolTipText: pal.text, + QPalette.ColorRole.Text: pal.text, + QPalette.ColorRole.Button: pal.panel_alt, + QPalette.ColorRole.ButtonText: pal.text, + QPalette.ColorRole.BrightText: "#ff5555", + QPalette.ColorRole.Highlight: pal.accent, + QPalette.ColorRole.HighlightedText: "#ffffff", + QPalette.ColorRole.Link: pal.accent, + QPalette.ColorRole.PlaceholderText: pal.text_muted, + QPalette.ColorRole.Light: pal.panel if pal.name == "light" else pal.panel_alt, + QPalette.ColorRole.Midlight: pal.panel_alt, + QPalette.ColorRole.Mid: pal.lane_border, + QPalette.ColorRole.Dark: pal.lane_border, + QPalette.ColorRole.Shadow: pal.text_muted if pal.name == "light" else "#000000", + } + for role, color in roles.items(): + qpal.setColor(role, QColor(color)) + for role in (QPalette.ColorRole.Text, QPalette.ColorRole.ButtonText, QPalette.ColorRole.WindowText): + qpal.setColor(QPalette.ColorGroup.Disabled, role, QColor(pal.text_muted)) + qapp.setPalette(qpal) + + font = QFont() + font.setFamilies(list(FONT_FAMILIES)) + font.setPointSize(FONT_POINT_SIZE) + qapp.setFont(font) + qapp.setStyleSheet(stylesheet(pal)) + return pal + + diff --git a/kokoro_gui/qt/timeline_view.py b/kokoro_gui/qt/timeline_view.py new file mode 100644 index 0000000..7307154 --- /dev/null +++ b/kokoro_gui/qt/timeline_view.py @@ -0,0 +1,807 @@ +"""Multi-track timeline on a real seconds axis (UI9 of +Claude/PLAN_ui_shell_redesign.md, section 4). + +`TimelineView` owns the scene: a ruler across the top, one lane per +`Document.track`, one `ClipBlockItem` per placed clip, and a playhead. x is +`seconds * self._zoom` (Ctrl+wheel, 20-400 px/s). Where a clip sits comes +from `kokoro_gui.daw.arrangement.compute_arrangement` - the same placement +the transport plays and the exporter writes - never from text offsets. +Generated clips draw a waveform; estimated (ungenerated) clips draw a +dashed outline and no waveform. + +`TrackHeaderView` is the fixed 120px column on the left holding track names +and a color swatch, a separate `QGraphicsView` whose vertical scrollbar +follows the main view's, so labels never overlap clips. `TimelineWidget` +composes the two. + +Mouse gestures on the main view (all resolved in `mouseReleaseEvent`): + +- click a block: select it (`SelectionModel`); click its FX chip: FX menu. +- click the ruler: `seekRequested(seconds)`. +- drag a block horizontally: `clipMoved(clip_id, new_start_s)` - the dock + pins the timestamp, and reorders the text if the drop lands before the + clip's text-order predecessor (grill Q13). Snaps to other clips' edges + and to the playhead within `SNAP_PX`. +- drag a block onto another lane: `clipDragReassigned` (the Q9 prompt). +- Shift+drag inside one block: `subRangeTtsRequested(clip_id, start, end)` + - item 9's sub-range TTS replacement, now behind Shift so a plain drag + can mean "move". + +The widget stays app-independent (no `self.app`): the dock owning +`app.document`/`app.engine` handles every signal. `render_document()` is a +full teardown-and-rebuild, fine for the clip counts a script has. +""" +from __future__ import annotations + +import time +from typing import Optional + +from PySide6.QtCore import QPointF, QRectF, Qt, Signal +from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QPolygonF +from PySide6.QtWidgets import ( + QGraphicsItem, QGraphicsLineItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSimpleTextItem, + QGraphicsView, QHBoxLayout, QMenu, QMessageBox, QWidget, +) + +from kokoro_gui.daw.arrangement import Arrangement, compute_arrangement +from kokoro_gui.qt import theme, waveform_data +from kokoro_gui.qt.fx_presets import list_fx_preset_names +from kokoro_gui.qt.selection import SelectionModel +from kokoro_gui.qt.waveform_view import WaveformItem + +RULER_HEIGHT_PX = 22.0 +LANE_HEIGHT_PX = 80.0 +LANE_MARGIN_PX = 8.0 +MIN_CLIP_WIDTH_PX = 20.0 +DEFAULT_PIXELS_PER_SECOND = 50.0 +MIN_PIXELS_PER_SECOND = 20.0 +MAX_PIXELS_PER_SECOND = 400.0 +HEADER_WIDTH_PX = 120 +CLIP_RADIUS_PX = 4.0 +# A clip's fill is its character color over the lane at this alpha: the +# label stays readable in both themes and the waveform (the color's darker +# shade) reads as one object with the block instead of a blue overlay. +CLIP_FILL_ALPHA = 200 +SNAP_PX = 8.0 +MIN_SCENE_SECONDS = 10.0 +AUTO_SCROLL_GRACE_S = 2.0 +FALLBACK_CLIP_COLOR = "#888888" +SELECTED_BORDER_WIDTH_PX = 3 +FX_BUTTON_WIDTH_PX = 24.0 +FX_BUTTON_HEIGHT_PX = 16.0 +FX_BUTTON_ACTIVE_OPACITY = 0.9 +FX_BUTTON_INACTIVE_OPACITY = 0.5 + + +def seconds_to_x(seconds: float, zoom: float) -> float: + return seconds * zoom + + +def x_to_seconds(x: float, zoom: float) -> float: + return max(0.0, x / zoom) if zoom > 0 else 0.0 + + +def lane_top(index: int) -> float: + return RULER_HEIGHT_PX + index * LANE_HEIGHT_PX + + +def choose_tick_step(zoom: float, min_label_px: float = 60.0) -> float: + """The tick spacing (seconds) that keeps labels at least + `min_label_px` apart at `zoom`: 1, 2, 5, 10, 15, 30, 60, ...""" + candidates = (0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600) + for step in candidates: + if step * zoom >= min_label_px: + return float(step) + return float(candidates[-1]) + + +def format_ruler_label(seconds: float) -> str: + minutes = int(seconds // 60) + rest = seconds - minutes * 60 + if seconds < 60 and rest != int(rest): + return f"{rest:.1f}s" + if minutes == 0: + return f"{int(rest)}s" + return f"{minutes}:{int(rest):02d}" + + +def label_color_for(fill: QColor) -> QColor: + """Black or white, whichever reads on `fill` (perceived luminance).""" + lum = 0.299 * fill.red() + 0.587 * fill.green() + 0.114 * fill.blue() + return QColor("#111111") if lum > 150 else QColor("#ffffff") + + +class ClipBlockItem(QGraphicsItem): + """One clip's block on a lane: character-colored fill, label, optional + child `WaveformItem`, FX chip in the bottom-right corner. Estimated + clips get a dashed outline and no waveform.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemClipsChildrenToShape, True) + self._width = 0.0 + self._height = 0.0 + self._color = FALLBACK_CLIP_COLOR + self._label = "" + self._waveform_item: WaveformItem | None = None + self._selected = False + self._fx_active = False + self._estimated = False + self.clip_id: Optional[str] = None + self.audio_path: Optional[str] = None + self.start_s = 0.0 + self.duration_s = 0.0 + + def set_clip_id(self, clip_id: str) -> None: + self.clip_id = clip_id + + def set_audio_path(self, audio_path: Optional[str]) -> None: + self.audio_path = audio_path + + def set_geometry(self, x: float, y: float, width: float, height: float) -> None: + self.prepareGeometryChange() + self.setPos(x, y) + self._width = width + self._height = height + self.update() + + def set_color(self, hex_color: str) -> None: + self._color = hex_color + self.update() + + def set_label(self, text: str) -> None: + self._label = text + self.update() + + def set_selected(self, selected: bool) -> None: + self._selected = selected + self.update() + + def set_fx_active(self, active: bool) -> None: + self._fx_active = active + self.update() + + def set_estimated(self, estimated: bool) -> None: + self._estimated = estimated + self.update() + + @property + def estimated(self) -> bool: + return self._estimated + + def fx_button_rect(self) -> QRectF: + width = min(FX_BUTTON_WIDTH_PX, self._width) + height = min(FX_BUTTON_HEIGHT_PX, self._height) + x = max(0.0, self._width - FX_BUTTON_WIDTH_PX) + y = max(0.0, self._height - FX_BUTTON_HEIGHT_PX) + return QRectF(x, y, width, height) + + def set_waveform(self, peaks, width: float, height: float) -> None: + if self._waveform_item is None: + self._waveform_item = WaveformItem(parent=self) + self._waveform_item.set_color(QColor(self._color).darker(170).name()) + self._waveform_item.set_peaks(peaks, width, height) + + def boundingRect(self) -> QRectF: # noqa: N802 (Qt override) + return QRectF(0, 0, self._width, self._height) + + def paint(self, painter, option, widget=None) -> None: # noqa: N802 (Qt override) + pal = theme.current() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + rect = QRectF(0.5, 0.5, self._width - 1, self._height - 1) + base = QColor(self._color) + fill = QColor(base) + fill.setAlpha(70 if self._estimated else CLIP_FILL_ALPHA) + if self._selected: + painter.setPen(QPen(QColor(pal.selection_border), SELECTED_BORDER_WIDTH_PX)) + elif self._estimated: + painter.setPen(QPen(QColor(pal.estimated_outline), 1, Qt.PenStyle.DashLine)) + else: + painter.setPen(QPen(base.darker(135), 1)) + painter.setBrush(fill) + painter.drawRoundedRect(rect, CLIP_RADIUS_PX, CLIP_RADIUS_PX) + if self._label: + painter.setPen(label_color_for(base) if not self._estimated else QColor(pal.text)) + painter.drawText(rect.adjusted(6, 3, -4, -2), 0, self._label) + + painter.setOpacity(FX_BUTTON_ACTIVE_OPACITY if self._fx_active else FX_BUTTON_INACTIVE_OPACITY) + fx_rect = self.fx_button_rect() + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(pal.fx_badge_bg)) + painter.drawRoundedRect(fx_rect, 4, 4) + painter.setPen(QPen(QColor(pal.fx_badge_text))) + painter.drawText(fx_rect, Qt.AlignmentFlag.AlignCenter, "FX") + painter.setOpacity(1.0) + + +class _RulerItem(QGraphicsItem): + """Ticks and mm:ss labels across the top of the scene, plus the + playhead's triangle.""" + + def __init__(self): + super().__init__() + self._width = 0.0 + self._zoom = DEFAULT_PIXELS_PER_SECOND + self._playhead_x: Optional[float] = None + self.setZValue(5) + + def set_span(self, width: float, zoom: float) -> None: + self.prepareGeometryChange() + self._width = width + self._zoom = zoom + self.update() + + def set_playhead_x(self, x: Optional[float]) -> None: + self._playhead_x = x + self.update() + + def boundingRect(self) -> QRectF: # noqa: N802 (Qt override) + return QRectF(0, 0, self._width, RULER_HEIGHT_PX) + + def paint(self, painter, option, widget=None) -> None: # noqa: N802 (Qt override) + pal = theme.current() + painter.fillRect(self.boundingRect(), QColor(pal.ruler_bg)) + painter.setPen(QPen(QColor(pal.ruler_text))) + step = choose_tick_step(self._zoom) + total_s = self._width / self._zoom if self._zoom > 0 else 0.0 + t = 0.0 + while t <= total_s + 1e-6: + x = seconds_to_x(t, self._zoom) + painter.drawLine(QPointF(x, RULER_HEIGHT_PX - 6), QPointF(x, RULER_HEIGHT_PX)) + painter.drawText(QRectF(x + 2, 0, step * self._zoom - 4, RULER_HEIGHT_PX - 4), + int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter), format_ruler_label(t)) + minor = t + step / 2 + if minor <= total_s: + mx = seconds_to_x(minor, self._zoom) + painter.drawLine(QPointF(mx, RULER_HEIGHT_PX - 3), QPointF(mx, RULER_HEIGHT_PX)) + t += step + painter.setPen(QPen(QColor(pal.lane_border))) + painter.drawLine(QPointF(0, RULER_HEIGHT_PX - 0.5), QPointF(self._width, RULER_HEIGHT_PX - 0.5)) + if self._playhead_x is not None: + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(pal.playhead)) + x = self._playhead_x + painter.drawPolygon(QPolygonF([ + QPointF(x - 6, RULER_HEIGHT_PX - 12), QPointF(x + 6, RULER_HEIGHT_PX - 12), QPointF(x, RULER_HEIGHT_PX - 1), + ])) + + +class _TrackLabelItem(QGraphicsSimpleTextItem): + """A track's name in the header column, click-selectable as that + lane's character. `shape()` returns the full rect so hit-testing near + the glyph edges doesn't miss.""" + + def __init__(self, text: str, character_id: Optional[str]): + super().__init__(text) + self.character_id = character_id + + def shape(self) -> QPainterPath: # noqa: N802 (Qt override) + path = QPainterPath() + path.addRect(self.boundingRect()) + return path + + +class TrackHeaderView(QGraphicsView): + """The fixed-width track header column.""" + + def __init__(self, parent=None, selection_model: Optional[SelectionModel] = None): + super().__init__(parent) + self._scene = QGraphicsScene(self) + self.setScene(self._scene) + self._selection_model = selection_model + self._labels: list = [] # keep-alive for Python-subclassed items + self.setFixedWidth(HEADER_WIDTH_PX) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) + self.setFrameShape(QGraphicsView.Shape.NoFrame) + + def render_tracks(self, tracks: list, document) -> None: + pal = theme.current() + self._scene.clear() + self._labels = [] + total_height = RULER_HEIGHT_PX + max(len(tracks), 1) * LANE_HEIGHT_PX + corner = QGraphicsRectItem(0, 0, HEADER_WIDTH_PX, RULER_HEIGHT_PX) + corner.setBrush(QColor(pal.ruler_bg)) + corner.setPen(QPen(QColor(pal.lane_border))) + self._scene.addItem(corner) + for i, track in enumerate(tracks): + y = lane_top(i) + bg = QGraphicsRectItem(0, y, HEADER_WIDTH_PX, LANE_HEIGHT_PX) + bg.setBrush(QColor(pal.panel_alt)) + bg.setPen(QPen(QColor(pal.lane_border))) + bg.setZValue(-1) + self._scene.addItem(bg) + character = document.get_character(track.character_id) if document is not None else None + swatch = QGraphicsRectItem(6, y + 8, 10, 10) + swatch.setBrush(QColor(character.highlight_color if character else FALLBACK_CLIP_COLOR)) + swatch.setPen(Qt.PenStyle.NoPen) + self._scene.addItem(swatch) + label = _TrackLabelItem(track.name, track.character_id) + label.setBrush(QColor(pal.text)) + label.setPos(22, y + 5) + self._scene.addItem(label) + self._labels.append(label) + self._scene.setSceneRect(0, 0, HEADER_WIDTH_PX, total_height) + self.setBackgroundBrush(QColor(pal.panel)) + + def mousePressEvent(self, event) -> None: # noqa: N802 (Qt override) + super().mousePressEvent(event) + if event.button() != Qt.MouseButton.LeftButton or self._selection_model is None: + return + item = self.itemAt(event.position().toPoint()) + character_id = getattr(item, "character_id", None) if item is not None else None + if character_id is not None: + self._selection_model.select_character(character_id) + + +class TimelineView(QGraphicsView): + generateClipRequested = Signal(str) + playClipRequested = Signal(str) # context-menu Play: seek the transport to the clip and play + fxPresetRequested = Signal(str, str) # (clip_id, preset_name); "" clears + clipDragReassigned = Signal(str, str, bool) # (clip_id, target_track_id, reassign_character) + subRangeTtsRequested = Signal(str, int, int) # (clip_id, sub_start, sub_end) text offsets + clipMoved = Signal(str, float) # (clip_id, new_start_s) + unpinRequested = Signal(str) + seekRequested = Signal(float) + zoomChanged = Signal(float) + + def __init__(self, parent=None, selection_model: Optional[SelectionModel] = None): + super().__init__(parent) + self._scene = QGraphicsScene(self) + self.setScene(self._scene) + self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) + + self._selection_model = selection_model + self._blocks_by_clip_id: dict = {} + self._selected_block: Optional[ClipBlockItem] = None + self._document = None + self._arrangement: Optional[Arrangement] = None + # Optional `(clip) -> (samples, rate) | None` the owner passes so the + # waveform shows the post-processed audio the transport plays + # (`QtTTSApp.rendered_clip_samples`); None draws the raw file. + self._clip_samples = None + self._zoom = DEFAULT_PIXELS_PER_SECOND + self._playhead_s: Optional[float] = None + self._playhead_item: Optional[QGraphicsLineItem] = None + self._ruler: Optional[_RulerItem] = None + self._lane_count = 0 + self._last_user_scroll = 0.0 + self._programmatic_scroll = False + + self._drag_clip_id: Optional[str] = None + self._drag_start_pos = None + self._drag_threshold_px = 8 + self._drag_clip_x_range: Optional[tuple] = None + self._drag_shift = False + + self.header: Optional[TrackHeaderView] = None + self.horizontalScrollBar().valueChanged.connect(self._on_hscroll) + if selection_model is not None: + selection_model.changed.connect(self._on_selection_changed) + + # -- zoom ---------------------------------------------------------------- + + @property + def zoom(self) -> float: + return self._zoom + + def set_zoom(self, pixels_per_second: float) -> None: + new_zoom = max(MIN_PIXELS_PER_SECOND, min(MAX_PIXELS_PER_SECOND, float(pixels_per_second))) + if new_zoom == self._zoom: + return + self._zoom = new_zoom + if self._document is not None: + self.render_document(self._document, self._arrangement) + self.zoomChanged.emit(self._zoom) + + def wheelEvent(self, event) -> None: # noqa: N802 (Qt override) + if event.modifiers() & Qt.KeyboardModifier.ControlModifier: + delta = event.angleDelta().y() + factor = 1.15 if delta > 0 else (1 / 1.15) + self.set_zoom(self._zoom * factor) + event.accept() + return + super().wheelEvent(event) + + def _on_hscroll(self, _value: int) -> None: + if not self._programmatic_scroll: + self._last_user_scroll = time.monotonic() + + # -- context menu ---------------------------------------------------------- + + def _clip_block_at(self, pos) -> Optional[ClipBlockItem]: + """The clip block under `pos`, looking through the playhead/grid + lines drawn above it and up from a child WaveformItem.""" + for item in self.items(pos): + while item is not None and not isinstance(item, ClipBlockItem): + item = item.parentItem() + if item is not None: + return item + return None + + def _build_context_menu(self, pos) -> Optional[QMenu]: + block = self._clip_block_at(pos) + if block is None or block.clip_id is None: + return None + + menu = QMenu(self) + generate_action = menu.addAction("Generate") + generate_action.triggered.connect( + lambda checked=False, cid=block.clip_id: self.generateClipRequested.emit(cid) + ) + + if block.audio_path: + play_action = menu.addAction("Play") + play_action.triggered.connect( + lambda checked=False, cid=block.clip_id: self.playClipRequested.emit(cid) + ) + + clip = self._document.get_clip(block.clip_id) if self._document is not None else None + if clip is not None and clip.timeline_timestamp is not None: + menu.addSeparator() + unpin = menu.addAction("Unpin from timeline") + unpin.triggered.connect(lambda checked=False, cid=block.clip_id: self.unpinRequested.emit(cid)) + + return menu + + def contextMenuEvent(self, event) -> None: # noqa: N802 (Qt override) + menu = self._build_context_menu(event.pos()) + if menu is not None: + menu.exec(event.globalPos()) + + # -- FX menu ----------------------------------------------------------------- + + def _build_fx_menu(self, block: ClipBlockItem) -> QMenu: + menu = QMenu(self) + for name in list_fx_preset_names(): + action = menu.addAction(name) + action.triggered.connect( + lambda checked=False, cid=block.clip_id, n=name: self.fxPresetRequested.emit(cid, n) + ) + menu.addSeparator() + clear_action = menu.addAction("Clear FX") + clear_action.triggered.connect( + lambda checked=False, cid=block.clip_id: self.fxPresetRequested.emit(cid, "") + ) + return menu + + def _handle_fx_button_click(self, block: ClipBlockItem, global_pos) -> None: + self._build_fx_menu(block).exec(global_pos) + + # -- mouse ------------------------------------------------------------------- + + def mousePressEvent(self, event) -> None: # noqa: N802 (Qt override) + super().mousePressEvent(event) + if event.button() != Qt.MouseButton.LeftButton: + return + pos = event.position().toPoint() + scene_pos = self.mapToScene(pos) + + if scene_pos.y() < RULER_HEIGHT_PX: + self.seekRequested.emit(x_to_seconds(scene_pos.x(), self._zoom)) + self._drag_clip_id = None + self._drag_start_pos = None + return + + block = self._clip_block_at(pos) + if block is not None and block.clip_id is not None: + local_pos = block.mapFromScene(scene_pos) + if block.fx_button_rect().contains(local_pos): + self._handle_fx_button_click(block, event.globalPosition().toPoint()) + return + + self._drag_start_pos = pos + self._drag_clip_id = block.clip_id if block is not None else None + self._drag_shift = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier) + if block is not None and block.clip_id is not None: + left = block.pos().x() + self._drag_clip_x_range = (left, left + block.boundingRect().width()) + else: + self._drag_clip_x_range = None + + if self._selection_model is None: + return + self._select_clip_block_at(pos) + + def _track_at_y(self, document, y: float): + tracks = sorted(document.tracks, key=lambda t: t.order_index) + index = int((y - RULER_HEIGHT_PX) // LANE_HEIGHT_PX) + if y < RULER_HEIGHT_PX: + return None + if 0 <= index < len(tracks): + return tracks[index] + return None + + def _snap_seconds(self, seconds: float, moving_clip_id: str) -> float: + """Snap to other clips' edges and the playhead within SNAP_PX.""" + candidates = [] + if self._arrangement is not None: + for placed in self._arrangement.placed: + if placed.clip.id == moving_clip_id: + continue + candidates.extend((placed.start_s, placed.end_s)) + if self._playhead_s is not None: + candidates.append(self._playhead_s) + candidates.append(0.0) + best = seconds + best_dist = SNAP_PX / self._zoom + for c in candidates: + d = abs(c - seconds) + if d <= best_dist: + best, best_dist = c, d + return max(0.0, best) + + def mouseReleaseEvent(self, event) -> None: # noqa: N802 (Qt override) + super().mouseReleaseEvent(event) + + clip_id = self._drag_clip_id + start_pos = self._drag_start_pos + clip_x_range = self._drag_clip_x_range + shift = self._drag_shift + self._drag_clip_id = None + self._drag_start_pos = None + self._drag_clip_x_range = None + self._drag_shift = False + + if clip_id is None or start_pos is None or self._document is None: + return + if event.button() != Qt.MouseButton.LeftButton: + return + + pos = event.position().toPoint() + delta = pos - start_pos + distance = (delta.x() ** 2 + delta.y() ** 2) ** 0.5 + if distance <= self._drag_threshold_px: + return # a plain click, already handled on press + + document = self._document + clip = document.get_clip(clip_id) + if clip is None: + return + + scene_pos = self.mapToScene(pos) + press_scene = self.mapToScene(start_pos) + target_track = self._track_at_y(document, scene_pos.y()) + + # Shift+drag inside one block: sub-range TTS replacement (item 9). + if shift and clip_x_range is not None: + extent = document.clip_extent(clip.id) + left, right = clip_x_range + if extent is not None and left <= press_scene.x() <= right and left <= scene_pos.x() <= right: + clip_start, clip_end = extent + span = max(right - left, 1e-6) + chars = clip_end - clip_start + press_offset = clip_start + round((press_scene.x() - left) / span * chars) + release_offset = clip_start + round((scene_pos.x() - left) / span * chars) + press_offset = max(clip_start, min(clip_end, press_offset)) + release_offset = max(clip_start, min(clip_end, release_offset)) + sub_start, sub_end = sorted((press_offset, release_offset)) + if sub_start != sub_end: + self.subRangeTtsRequested.emit(clip.id, sub_start, sub_end) + return + + moved_horizontally = abs(delta.x()) > self._drag_threshold_px + if moved_horizontally and clip_x_range is not None: + left, _right = clip_x_range + new_left = left + (scene_pos.x() - press_scene.x()) + new_start = self._snap_seconds(x_to_seconds(new_left, self._zoom), clip.id) + self.clipMoved.emit(clip.id, new_start) + + if target_track is None or target_track.id == clip.track_id: + return + + if target_track.character_id is None or target_track.character_id == clip.character_id: + self.clipDragReassigned.emit(clip.id, target_track.id, False) + return + + msg = QMessageBox(self) + msg.setWindowTitle("Reassign Character?") + msg.setText( + "This track belongs to a different character. Reassign this " + "clip's character to match the track, or just move it to this " + "lane while keeping its own character?" + ) + reassign_btn = msg.addButton("Reassign", QMessageBox.ButtonRole.AcceptRole) + move_btn = msg.addButton("Just Move", QMessageBox.ButtonRole.ActionRole) + msg.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + msg.exec() + clicked = msg.clickedButton() + + if clicked is reassign_btn: + self.clipDragReassigned.emit(clip.id, target_track.id, True) + elif clicked is move_btn: + self.clipDragReassigned.emit(clip.id, target_track.id, False) + + def _select_clip_block_at(self, pos) -> None: + block = self._clip_block_at(pos) + if block is not None: + if block.clip_id is not None: + self._selection_model.select_clip(block.clip_id) + return + item = self.itemAt(pos) + character_id = getattr(item, "character_id", None) if item is not None else None + if character_id is not None: + self._selection_model.select_character(character_id) + else: + self._selection_model.clear() + + def _on_selection_changed(self) -> None: + if self._selected_block is not None: + self._selected_block.set_selected(False) + self._selected_block = None + + clip_id = self._selection_model.selected_clip_id + block = self._blocks_by_clip_id.get(clip_id) if clip_id is not None else None + if block is not None: + block.set_selected(True) + self.ensureVisible(block) + self._selected_block = block + + # -- playhead -------------------------------------------------------------------- + + def set_playhead(self, seconds: Optional[float]) -> None: + self._playhead_s = seconds + if self._playhead_item is None: + return + if seconds is None: + self._playhead_item.hide() + if self._ruler is not None: + self._ruler.set_playhead_x(None) + return + x = seconds_to_x(seconds, self._zoom) + height = RULER_HEIGHT_PX + max(self._lane_count, 1) * LANE_HEIGHT_PX + self._playhead_item.setLine(x, RULER_HEIGHT_PX, x, height) + self._playhead_item.show() + if self._ruler is not None: + self._ruler.set_playhead_x(x) + if time.monotonic() - self._last_user_scroll > AUTO_SCROLL_GRACE_S: + viewport_left = self.mapToScene(0, 0).x() + viewport_right = self.mapToScene(self.viewport().width(), 0).x() + if x < viewport_left or x > viewport_right - 20: + self._programmatic_scroll = True + try: + self.horizontalScrollBar().setValue(int(x - 40)) + finally: + self._programmatic_scroll = False + + # -- rendering ----------------------------------------------------------------- + + def set_arrangement(self, arrangement: Arrangement) -> None: + if self._document is None: + return + self.render_document(self._document, arrangement) + + def _peaks_for(self, clip, fallback_path: str, bucket_count: int): + """Waveform peaks from the owner's rendered samples when it gave us a + `clip_samples` callable, else from the raw first-segment file.""" + if self._clip_samples is not None: + rendered = self._clip_samples(clip) + if rendered is not None: + samples, rate = rendered + return waveform_data.compute_peaks(samples, rate, bucket_count) + peaks, _duration = waveform_data.load_peaks_from_file(fallback_path, bucket_count) + return peaks + + def render_document(self, document, arrangement: Optional[Arrangement] = None, + clip_samples=None) -> None: + pal = theme.current() + self._document = document + if arrangement is None: + arrangement = compute_arrangement(document) + self._arrangement = arrangement + if clip_samples is not None: + self._clip_samples = clip_samples + + self._scene.clear() + self._blocks_by_clip_id = {} + self._selected_block = None + self._playhead_item = None + self._ruler = None + + tracks = sorted(document.tracks, key=lambda t: t.order_index) + lane_index_by_track_id = {track.id: i for i, track in enumerate(tracks)} + self._lane_count = len(tracks) + + visible_seconds = self.viewport().width() / self._zoom if self._zoom > 0 else 0.0 + total_seconds = max(arrangement.total_duration_s + 2.0, visible_seconds, MIN_SCENE_SECONDS) + total_width = seconds_to_x(total_seconds, self._zoom) + total_height = RULER_HEIGHT_PX + max(len(tracks), 1) * LANE_HEIGHT_PX + + for i, _track in enumerate(tracks): + y = lane_top(i) + lane_rect = QGraphicsRectItem(0, y, total_width, LANE_HEIGHT_PX) + lane_rect.setBrush(QColor(pal.lane_bg if i % 2 == 0 else pal.lane_alt_bg)) + lane_rect.setPen(QPen(QColor(pal.lane_border))) + lane_rect.setZValue(-1) + self._scene.addItem(lane_rect) + + # Grid lines at the ruler's ticks, under the clips. + step = choose_tick_step(self._zoom) + t = step + while t < total_seconds: + x = seconds_to_x(t, self._zoom) + grid = QGraphicsLineItem(x, RULER_HEIGHT_PX, x, total_height) + grid_color = QColor(pal.lane_border) + grid_color.setAlpha(120) + grid.setPen(QPen(grid_color, 1, Qt.PenStyle.DotLine)) + grid.setZValue(-0.5) + self._scene.addItem(grid) + t += step + + for placed in arrangement.placed: + clip = placed.clip + track = document.get_track(clip.track_id) + if track is None: + continue + character = document.get_character(clip.character_id) + color = character.highlight_color if character is not None else FALLBACK_CLIP_COLOR + + x = seconds_to_x(placed.start_s, self._zoom) + width = max(seconds_to_x(placed.duration_s, self._zoom), MIN_CLIP_WIDTH_PX) + y = lane_top(lane_index_by_track_id[track.id]) + LANE_MARGIN_PX + height = LANE_HEIGHT_PX - 2 * LANE_MARGIN_PX + + block = ClipBlockItem() + block.set_clip_id(clip.id) + block.set_color(color) + block.set_label(character.name if character is not None else "") + block.set_geometry(x, y, width, height) + block.set_estimated(placed.estimated) + block.start_s = placed.start_s + block.duration_s = placed.duration_s + has_character_fx = bool(character is not None and character.preset_data.get("fx_preset") + and character.preset_data.get("fx_preset") != "Select FX Preset...") + block.set_fx_active(bool(clip.fx_override) or bool(clip.overrides.get("fx_preset")) or has_character_fx) + self._blocks_by_clip_id[clip.id] = block + self._scene.addItem(block) + + audio_segment = next((s for s in clip.segments if s.audio_path), None) + block.set_audio_path(audio_segment.audio_path if audio_segment is not None else None) + if audio_segment is not None and not placed.estimated: + try: + peaks = self._peaks_for(clip, audio_segment.audio_path, max(1, int(width))) + except Exception: + peaks = None + if peaks is not None: + block.set_waveform(peaks, width, height) + + self._ruler = _RulerItem() + self._ruler.set_span(total_width, self._zoom) + self._scene.addItem(self._ruler) + + self._playhead_item = QGraphicsLineItem() + self._playhead_item.setPen(QPen(QColor(pal.playhead), 2)) + self._playhead_item.setZValue(10) + self._playhead_item.hide() + self._scene.addItem(self._playhead_item) + + self._scene.setSceneRect(0, 0, total_width, total_height) + self.setBackgroundBrush(QColor(pal.panel)) + if self.header is not None: + self.header.render_tracks(tracks, document) + + if self._playhead_s is not None: + self.set_playhead(self._playhead_s) + if self._selection_model is not None: + self._on_selection_changed() + + +class TimelineWidget(QWidget): + """Header column + timeline view with linked vertical scrolling.""" + + def __init__(self, parent=None, selection_model: Optional[SelectionModel] = None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.header = TrackHeaderView(selection_model=selection_model) + self.view = TimelineView(selection_model=selection_model) + self.view.header = self.header + layout.addWidget(self.header) + layout.addWidget(self.view, 1) + self.view.verticalScrollBar().valueChanged.connect(self.header.verticalScrollBar().setValue) + + def render_document(self, document, arrangement: Optional[Arrangement] = None, + clip_samples=None) -> None: + self.view.render_document(document, arrangement, clip_samples=clip_samples) diff --git a/kokoro_gui/qt/transcript_editor.py b/kokoro_gui/qt/transcript_editor.py new file mode 100644 index 0000000..b769ea3 --- /dev/null +++ b/kokoro_gui/qt/transcript_editor.py @@ -0,0 +1,749 @@ +"""The transcript panel's text editor: a `QTextEdit` that stays in sync with +a `kokoro_gui.daw.models.Document`, paints per-character highlighting, and +offers a right-click Characters menu for UI-driven voice assignment (Q20 - +the authoring path that coexists with the `[Speaker:FX]:` inline syntax, +lowering into the same clip metadata). + +Rebuilt per Claude/PLAN_text_editor_redesign.md's "core inversion": the +tagged run list (`Document.runs`) is primary, plain text is a derived view. +Concretely: + +- **`ClipHighlighter` is a single pass over `app.document.runs`** - the + `[Speaker:FX]:` shorthand converts into a real tagged run the moment it's + recognized, so there's no separately-overlaid "recognized but un-tagged" + text any more. +- **Highlighting stays a `QSyntaxHighlighter` overlay, not a real edit to + the `QTextDocument`'s formatting.** `QTextDocument.setUndoRedoEnabled()` + clears the undo history on every transition in this PySide6 version, so + painting through `cursor.setCharFormat()` would wipe typing history each + time a clip gets tagged. A highlighter's `setFormat()` never touches the + document's real formatting or its undo stack. +- **Clip identity is never read back off the live `QTextDocument`.** "What + clip covers this position" always resolves through + `app.document.clip_covering`/`clip_extent`. A highlighter overlay carries + nothing into the clipboard, so `CHARACTER_ID_MIME_TYPE` stays as an + explicit side channel for paste-splitting. +- **Undo is coordinated, not merged**: typing rides the `QTextDocument`'s + native undo; character assignment goes through `app.document.undo_stack`. + `undo_coordinator` pops whichever acted most recently. +- **`[Speaker:FX]:` shorthand (TE6)** converts on completing the line + (Enter, or focus-out for a last line without one). + +UI-shell pass (Claude/PLAN_ui_shell_redesign.md section 2): + +- The gutter labels once per `(character, fx)` change, as two lines + (`Narrator` / `FX: Echo`), and draws a play button beside each dirty + clip's first line (UI3). Clicking it runs `app.generate_clip(clip_id)`. +- Runs belonging to a dirty clip get a dashed underline + (`ClipHighlighter`'s second pass). +- Split rules (UI2): a thin line at every boundary `plan_auto_split_clips` + would produce with the current "split by paragraph" setting, plus every + existing clip boundary, painted over the viewport after `super()`. + Recomputed 150ms after the last edit. +- The clip being played back (`SelectionModel.playing_clip_id`) is shown as + a translucent `ExtraSelection` and scrolled into view (UI4). +- Colors come from `kokoro_gui.qt.theme.current()` (UI10). + +Note: `self.document()` (Qt's `QTextDocument`) and `self.app.document` (the +DAW `Document`) are two different objects with the same short name. Always +spell `self.app.document` out in full in this class. +""" +from __future__ import annotations + +import re +from typing import Callable, Optional + +from PySide6.QtCore import QMimeData, QRect, QSize, Qt, QTimer +from PySide6.QtGui import ( + QColor, QFont, QKeySequence, QPainter, QPen, QPolygon, QSyntaxHighlighter, QTextCharFormat, + QTextCursor, +) +from PySide6.QtCore import QPoint +from PySide6.QtWidgets import QMenu, QTextEdit, QWidget + +from kokoro_gui.daw.auto_split import plan_auto_split_clips +from kokoro_gui.daw.undo import AssignCharacterCommand +from kokoro_gui.qt import theme +from kokoro_gui.qt.undo_coordinator import UndoCoordinator + +# Same tag syntax kokoro_gui.engine.text_extraction._SPEAKER_FX_TAG_PATTERN +# matches, anchored to the start of a line rather than searched anywhere in +# it - the "on completing the line" recognition only ever considers whether +# the line, as a whole, OPENS with a tag (see _try_recognize_shorthand_line). +_SHORTHAND_LINE_PATTERN = re.compile(r"^\[([^\]\n]{1,100})\]:\s*") + +GUTTER_WIDTH_PX = 140 +# Character highlights tint the text rather than paint over it, so the +# same hex reads on the light and the dark panel. +HIGHLIGHT_ALPHA = 90 +GUTTER_BUTTON_PX = 16 +SPLIT_RULE_DEBOUNCE_MS = 150 +_FX_PLACEHOLDER = "Select FX Preset..." + + +def clip_fx_name(daw_doc, clip) -> Optional[str]: + """The FX preset name a clip resolves to for display: its own named + override (`overrides["fx_preset"]`, set by the FX combo / Settings tab + / timeline FX menu), else "custom" when it carries resolved + `fx_override` values with no recorded name, else the character's + attached `fx_preset`, else None.""" + if clip is None: + return None + own = clip.overrides.get("fx_preset") + if own and own != _FX_PLACEHOLDER: + return own + if clip.fx_override: + return "custom" + character = daw_doc.get_character(clip.character_id) + if character is not None: + name = character.preset_data.get("fx_preset") + if name and name != _FX_PLACEHOLDER: + return name + return None + + +class ClipHighlighter(QSyntaxHighlighter): + """Paints each text block by whichever `Clip` a run covers, using the + matching `Character`'s `highlight_color`, then dash-underlines the runs + of every dirty clip. `dirty_ids()` is computed once per rehighlight + cycle and invalidated by the editor on every content change and after + generation finishes. + + `daw_document_provider` is a zero-arg callable returning the current + `kokoro_gui.daw.models.Document` (not a captured reference), so a + "switch project" action that reassigns `app.document` doesn't require + rebuilding this highlighter. + """ + + def __init__(self, qt_text_document, daw_document_provider: Callable[[], object]): + super().__init__(qt_text_document) + self._daw_document_provider = daw_document_provider + self._dirty_ids: Optional[set] = None + + def invalidate_dirty(self) -> None: + self._dirty_ids = None + + def dirty_ids(self) -> set: + if self._dirty_ids is None: + daw_doc = self._daw_document_provider() + try: + self._dirty_ids = {c.id for c in daw_doc.dirty_clips()} if daw_doc is not None else set() + except Exception: + self._dirty_ids = set() + return self._dirty_ids + + def rehighlight(self) -> None: # noqa: N802 (Qt override) + self.invalidate_dirty() + super().rehighlight() + + def highlightBlock(self, block_text: str) -> None: # noqa: N802 (Qt override) + daw_doc = self._daw_document_provider() + if daw_doc is None: + return + + block_start = self.currentBlock().position() + block_end = block_start + len(block_text) + dirty = self.dirty_ids() + underline_color = QColor(theme.current().dirty_underline) + + pos = 0 + for run in daw_doc.runs: + run_start, run_end = pos, pos + len(run.text) + pos = run_end + if run.clip_id is None or run_start >= block_end or run_end <= block_start: + continue + clip = daw_doc.get_clip(run.clip_id) + character = daw_doc.get_character(clip.character_id) if clip is not None else None + lo = max(run_start, block_start) - block_start + hi = min(run_end, block_end) - block_start + if hi <= lo: + continue + fmt = QTextCharFormat() + if character is not None: + tint = QColor(character.highlight_color) + tint.setAlpha(HIGHLIGHT_ALPHA) + fmt.setBackground(tint) + if clip is not None and clip.id in dirty: + fmt.setUnderlineStyle(QTextCharFormat.UnderlineStyle.DashUnderline) + fmt.setUnderlineColor(underline_color) + self.setFormat(lo, hi - lo, fmt) + + +class TranscriptGutter(QWidget): + """Left gutter beside the transcript editor (TE3, reshaped by UI3). + Shows the character name and, on a second line, the resolved FX preset + wherever the `(character, fx)` pair changes from the previous line, + and a small play button beside each dirty clip's first visible line. + Labels open a character picker for the clicked clip; the button runs a + scoped Generate for that one clip. + + Built as a child of `TranscriptEditor` itself - `QTextEdit` has no + public `firstVisibleBlock()`/`contentOffset()`, so lines are positioned + via `document().documentLayout().blockBoundingRect(block)` translated + by the editor's vertical scrollbar value. + """ + + def __init__(self, editor: "TranscriptEditor"): + super().__init__(editor) + self.editor = editor + self._label_rects: list = [] # [(QRect, line_start, line_end)] + self._button_rects: list = [] # [(QRect, clip_id)] + self.setMouseTracking(True) + editor.verticalScrollBar().valueChanged.connect(lambda _value: self.update()) + editor.textChanged.connect(self.update) + + def sizeHint(self) -> QSize: # noqa: N802 (Qt override) + return QSize(GUTTER_WIDTH_PX, 0) + + def _label_key(self, daw_doc, clip): + if clip is None: + return (None, None) + return (clip.character_id, clip_fx_name(daw_doc, clip)) + + def paintEvent(self, event) -> None: # noqa: N802 (Qt override) + pal = theme.current() + painter = QPainter(self) + painter.fillRect(event.rect(), QColor(pal.gutter_bg)) + self._label_rects = [] + self._button_rects = [] + + daw_doc = self.editor.app.document + qt_doc = self.editor.document() + layout = qt_doc.documentLayout() + scroll = self.editor.verticalScrollBar().value() + dirty_ids = {c.id for c in daw_doc.dirty_clips()} + labelled_dirty: set = set() + + base_font = QFont(self.font()) + small_font = QFont(base_font) + small_font.setPointSizeF(max(6.0, base_font.pointSizeF() - 1.5)) + metrics_h = painter.fontMetrics().height() + + previous_key = None + block = qt_doc.begin() + while block.isValid(): + rect = layout.blockBoundingRect(block).translated(0, -scroll) + if rect.bottom() < 0: + # Off the top - still track the key so the first visible + # line labels only if it differs from the hidden line above. + clip = daw_doc.clip_covering(block.position()) + previous_key = self._label_key(daw_doc, clip) + if clip is not None and clip.id in dirty_ids: + labelled_dirty.add(clip.id) + block = block.next() + continue + if rect.top() > self.height(): + break + + line_start = block.position() + line_end = line_start + len(block.text()) + clip = daw_doc.clip_covering(line_start) + character = daw_doc.get_character(clip.character_id) if clip is not None else None + key = self._label_key(daw_doc, clip) + + top = int(rect.top()) + line_h = max(int(rect.height()), 1) + text_right = self.width() - GUTTER_BUTTON_PX - 10 + + if key != previous_key and character is not None: + name_rect = QRect(4, top, text_right - 4, metrics_h) + painter.setFont(base_font) + painter.setPen(QColor(character.highlight_color)) + painter.drawText(name_rect, int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft), + painter.fontMetrics().elidedText(character.name, Qt.TextElideMode.ElideRight, + name_rect.width())) + fx_name = key[1] + label_height = metrics_h + if fx_name: + fx_text = f"FX: {fx_name}" + if line_h >= 2 * metrics_h - 2: + fx_rect = QRect(4, top + metrics_h, text_right - 4, metrics_h) + painter.setFont(small_font) + painter.setPen(QColor(pal.gutter_text)) + painter.drawText(fx_rect, int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft), + painter.fontMetrics().elidedText(fx_text, Qt.TextElideMode.ElideRight, + fx_rect.width())) + label_height = 2 * metrics_h + else: + # One-line run: the FX line goes to the tooltip. + self.setToolTip(fx_text) + label_rect = QRect(4, top, text_right - 4, max(label_height, line_h)) + self._label_rects.append((label_rect, line_start, line_end)) + + if clip is not None and clip.id in dirty_ids and clip.id not in labelled_dirty: + labelled_dirty.add(clip.id) + btn = QRect(self.width() - GUTTER_BUTTON_PX - 4, top + max(0, (min(line_h, metrics_h) - GUTTER_BUTTON_PX) // 2), + GUTTER_BUTTON_PX, GUTTER_BUTTON_PX) + self._draw_play_button(painter, btn, pal) + self._button_rects.append((btn, clip.id)) + + previous_key = key + block = block.next() + + @staticmethod + def _draw_play_button(painter: QPainter, rect: QRect, pal) -> None: + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(QPen(QColor(pal.dirty_underline), 1)) + painter.setBrush(QColor(pal.dirty_underline)) + tri = QPolygon([ + QPoint(rect.left() + 4, rect.top() + 3), + QPoint(rect.right() - 3, rect.center().y()), + QPoint(rect.left() + 4, rect.bottom() - 2), + ]) + painter.drawPolygon(tri) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) + + def button_rects(self) -> list: + return list(self._button_rects) + + def mousePressEvent(self, event) -> None: # noqa: N802 (Qt override) + pos = event.position().toPoint() + for rect, clip_id in self._button_rects: + if rect.contains(pos): + self.editor.app.generate_clip(clip_id) + return + for rect, line_start, line_end in self._label_rects: + if rect.contains(pos): + menu = self._build_picker_menu(line_start, line_end) + if menu is not None: + menu.exec(event.globalPosition().toPoint()) + return + + def _build_picker_menu(self, line_start: int, line_end: int) -> Optional[QMenu]: + """Split out from `mousePressEvent` so tests can inspect/trigger the + picker without ever calling the blocking `.exec()`. Widens + `[line_start, line_end)` out to the clicked clip's full extent first + (a label represents the whole clip, not just the one line it happens + to be painted next to).""" + daw_doc = self.editor.app.document + clip = daw_doc.clip_covering(line_start) + if clip is not None: + extent = daw_doc.clip_extent(clip.id) + if extent is not None: + line_start, line_end = extent + if line_end <= line_start: + return None + + menu = QMenu(self) + for character in daw_doc.characters: + action = menu.addAction(character.name) + action.triggered.connect( + lambda checked=False, cid=character.id: self.editor._push_assign_character(line_start, line_end, cid) + ) + return menu + + +class TranscriptEditor(QTextEdit): + """The transcript panel's editor (see docks/transcript_dock.py). Keeps + `app.document` in sync with every keystroke via `Document.replace_text`, + and adds a Characters-menu/copy-paste/shorthand authoring path on top of + plain text entry. + """ + + CHARACTER_ID_MIME_TYPE = "application/x-kokorogui-character-id" + + def __init__(self, app, parent=None): + super().__init__(parent) + self.app = app + # Ordinary typing rides Qt's own native undo/redo - see the module + # docstring. + self.setUndoRedoEnabled(True) + self._suppress_contents_change = False + # None outside of an active insertFromMimeData call; an int + # accumulator while one is in progress, since a single paste can + # fire more than one contentsChange signal (e.g. removing a prior + # selection, then inserting) and the *net* chars added is what + # assign_character_to_range needs. + self._paste_chars_accumulator: Optional[int] = None + # Guards against _on_selection_model_changed's own setTextCursor() + # call bouncing straight back into _on_cursor_position_changed. + self._updating_from_model = False + + self._highlighter = ClipHighlighter(self.document(), lambda: self.app.document) + self.undo_coordinator = UndoCoordinator( + self.document(), self.app.document.undo_stack, self._on_custom_stack_changed + ) + + # Left gutter - reserves its own width via setViewportMargins so it + # scrolls/resizes in lockstep with the text (see resizeEvent). + self._gutter = TranscriptGutter(self) + self.setViewportMargins(GUTTER_WIDTH_PX, 0, 0, 0) + + # Split rules (UI2): boundaries as document offsets, recomputed on a + # debounce after edits. + self._split_boundaries: list = [] + self._split_timer = QTimer(self) + self._split_timer.setSingleShot(True) + self._split_timer.setInterval(SPLIT_RULE_DEBOUNCE_MS) + self._split_timer.timeout.connect(self.refresh_split_rules) + + self._playing_clip_id: Optional[str] = None + + self.document().contentsChange.connect(self._on_contents_change) + self.cursorPositionChanged.connect(self._on_cursor_position_changed) + self.app.selection.changed.connect(self._on_selection_model_changed) + if hasattr(self.app.selection, "playingChanged"): + self.app.selection.playingChanged.connect(self._on_playing_changed) + if hasattr(self.app, "themeChanged"): + self.app.themeChanged.connect(self._on_theme_changed) + + self.load_text(self.app.document.text) + self._apply_theme_colors() + + def resizeEvent(self, event) -> None: # noqa: N802 (Qt override) + super().resizeEvent(event) + self._gutter.setGeometry(0, 0, GUTTER_WIDTH_PX, self.height()) + + # -- theme --------------------------------------------------------------- + + def _apply_theme_colors(self) -> None: + pal = theme.current() + self.setStyleSheet(f"QTextEdit {{ background: {pal.panel}; color: {pal.text}; }}") + font = QFont(self.font()) + font.setPointSize(theme.EDITOR_FONT_POINT_SIZE) + self.setFont(font) + + def _on_theme_changed(self) -> None: + self._apply_theme_colors() + self.rehighlight() + self.viewport().update() + + # -- Document sync ----------------------------------------------------- + + def _on_contents_change(self, position: int, chars_removed: int, chars_added: int) -> None: + if self._paste_chars_accumulator is not None: + self._paste_chars_accumulator += chars_added + if self._suppress_contents_change: + return + new_text = self.toPlainText() + self.app.document.replace_text(position, chars_removed, chars_added, new_text) + # The highlighter's own contentsChange slot ran before this one (it + # connected first, at construction) against the pre-edit run list - + # re-paint the touched blocks now that the run list caught up. + self._highlighter.invalidate_dirty() + first = self.document().findBlock(position) + last = self.document().findBlock(position + max(chars_added, 0)) + block = first + while block.isValid(): + self._highlighter.rehighlightBlock(block) + if block == last: + break + block = block.next() + self._split_timer.start() + self.app.schedule_save() + self.app.refresh_timeline() + + def load_text(self, text: str) -> None: + """Sets the editor's text without treating it as a user edit - + `app.document.replace_text` is not called. Used at construction to + seed from `app.document.text`, and by "load a different project".""" + self._suppress_contents_change = True + try: + self.setPlainText(text) + finally: + self._suppress_contents_change = False + self.rehighlight() + + def rebind_document(self) -> None: + """After `app.document` was swapped for another `Document` (File > + New/Open): reload the text, point the undo coordinator at the new + stack, repaint.""" + self.undo_coordinator = UndoCoordinator( + self.document(), self.app.document.undo_stack, self._on_custom_stack_changed + ) + self.document().clearUndoRedoStacks() + self.load_text(self.app.document.text) + + def rehighlight(self) -> None: + """Repaints every block's highlight (and the gutter's labels) from + `app.document.runs` - the one entry point every tagging operation + calls after mutating the document, and what a custom-stack + undo/redo calls too.""" + self._highlighter.rehighlight() + self._gutter.update() + self.refresh_split_rules() + + def _on_custom_stack_changed(self) -> None: + """Called by `undo_coordinator` after every custom-stack undo/redo - + a custom-stack action changed `app.document.runs`/`clips` directly, + with no signal Qt can observe on its own.""" + self.rehighlight() + self.app.schedule_save() + self.app.refresh_timeline() + + def _push_assign_character(self, start: int, end: int, character_id) -> None: + """Shared tail end of every character-assignment authoring path + (Characters menu, gutter picker, header combo, paste-splitting, the + `[Speaker:FX]:` shorthand).""" + self.app.document.undo_stack.push(AssignCharacterCommand(start, end, character_id)) + self.rehighlight() + self.app.schedule_save() + self.app.refresh_timeline() + + # -- split rules (UI2) --------------------------------------------------- + + def split_boundaries(self) -> list: + return list(self._split_boundaries) + + def refresh_split_rules(self) -> None: + daw_doc = self.app.document + text_len = len(daw_doc.text) + boundaries = set() + for clip in daw_doc.clips: + extent = daw_doc.clip_extent(clip.id) + if extent is not None: + boundaries.add(extent[0]) + boundaries.add(extent[1]) + try: + triples, _unmatched = plan_auto_split_clips( + daw_doc, split_by_paragraph=bool(self.app.settings.get("auto_split_by_paragraph", False)) + ) + except Exception: + triples = [] + for start, end, _cid in triples: + boundaries.add(start) + boundaries.add(end) + boundaries.discard(0) + boundaries.discard(text_len) + self._split_boundaries = sorted(b for b in boundaries if 0 < b < text_len) + self.viewport().update() + + def paintEvent(self, event) -> None: # noqa: N802 (Qt override) + super().paintEvent(event) + if not self._split_boundaries: + return + pal = theme.current() + painter = QPainter(self.viewport()) + pen = QPen(QColor(pal.split_rule), 1, Qt.PenStyle.DashLine) + painter.setPen(pen) + width = self.viewport().width() + text = self.toPlainText() + for offset in self._split_boundaries: + cursor = QTextCursor(self.document()) + cursor.setPosition(min(offset, len(text))) + rect = self.cursorRect(cursor) + if rect.bottom() < 0 or rect.top() > self.viewport().height(): + continue + at_line_start = offset == 0 or text[offset - 1] == "\n" + if at_line_start: + y = rect.top() + painter.drawLine(0, y, width, y) + else: + # Mid-line boundary: a short vertical tick at the caret x. + painter.drawLine(rect.left(), rect.top(), rect.left(), rect.bottom()) + + # -- playing clip (UI4) -------------------------------------------------- + + def _on_playing_changed(self) -> None: + clip_id = self.app.selection.playing_clip_id + if clip_id == self._playing_clip_id: + return + self._playing_clip_id = clip_id + selections = [] + if clip_id is not None: + extent = self.app.document.clip_extent(clip_id) + clip = self.app.document.get_clip(clip_id) + if extent is not None and clip is not None: + character = self.app.document.get_character(clip.character_id) + color = QColor(character.highlight_color) if character else QColor(theme.current().playing_highlight) + color.setAlpha(110) + sel = QTextEdit.ExtraSelection() + sel.cursor = QTextCursor(self.document()) + sel.cursor.setPosition(extent[0]) + sel.cursor.setPosition(extent[1], QTextCursor.MoveMode.KeepAnchor) + sel.format.setBackground(color) + selections.append(sel) + self._scroll_offset_into_view(extent[0]) + self.setExtraSelections(selections) + + def _scroll_offset_into_view(self, offset: int) -> None: + cursor = QTextCursor(self.document()) + cursor.setPosition(max(0, min(offset, len(self.toPlainText())))) + rect = self.cursorRect(cursor) + bar = self.verticalScrollBar() + viewport_h = self.viewport().height() + if rect.top() < 0: + bar.setValue(bar.value() + rect.top() - 8) + elif rect.bottom() > viewport_h: + bar.setValue(bar.value() + rect.bottom() - viewport_h + 8) + + # -- Selection sync ------------------------------------------------------- + + def _on_cursor_position_changed(self) -> None: + if self._updating_from_model: + return + cursor = self.textCursor() + clip = self.app.document.clip_covering(cursor.selectionStart()) + if clip is not None: + self.app.selection.select_clip(clip.id) + elif cursor.hasSelection(): + self.app.selection.select_range(cursor.selectionStart(), cursor.selectionEnd()) + else: + self.app.selection.clear() + + def _on_selection_model_changed(self) -> None: + clip_id = self.app.selection.selected_clip_id + if clip_id is None: + return + clip = self.app.document.get_clip(clip_id) + if clip is None: + return + extent = self.app.document.clip_extent(clip_id) + if extent is None: + return + start, end = extent + + cursor = self.textCursor() + if cursor.selectionStart() == start and cursor.selectionEnd() == end: + return + if start <= cursor.position() < end and not cursor.hasSelection(): + return # the caret already sits inside this clip - leave it alone + + self._updating_from_model = True + try: + text_len = len(self.toPlainText()) + start = max(0, min(start, text_len)) + end = max(0, min(end, text_len)) + new_cursor = QTextCursor(self.document()) + new_cursor.setPosition(start) + new_cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor) + self.setTextCursor(new_cursor) + self.ensureCursorVisible() + finally: + self._updating_from_model = False + + # -- current target for the header combos --------------------------------- + + def current_target_range(self) -> Optional[tuple]: + """The `[start, end)` a header-combo change applies to: the text + selection if there is one, else the caret's whole clip, else the + caret's line. None for an empty document.""" + cursor = self.textCursor() + if cursor.hasSelection(): + return (cursor.selectionStart(), cursor.selectionEnd()) + clip = self.app.document.clip_covering(cursor.position()) + if clip is not None: + return self.app.document.clip_extent(clip.id) + block = cursor.block() + start = block.position() + end = start + len(block.text()) + return (start, end) if end > start else None + + def current_clip(self): + cursor = self.textCursor() + return self.app.document.clip_covering(cursor.selectionStart()) + + # -- Undo/redo coordination + [Speaker:FX]: shorthand recognition ------- + + def keyPressEvent(self, event) -> None: # noqa: N802 (Qt override) + if event.matches(QKeySequence.StandardKey.Undo): + self.undo_coordinator.undo() + event.accept() + return + if event.matches(QKeySequence.StandardKey.Redo): + self.undo_coordinator.redo() + event.accept() + return + + pending_line = None + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and not self.textCursor().hasSelection(): + block = self.textCursor().block() + pending_line = (block.position(), block.text()) + + super().keyPressEvent(event) + + if pending_line is not None: + self._try_recognize_shorthand_line(*pending_line) + + def focusOutEvent(self, event) -> None: # noqa: N802 (Qt override) + super().focusOutEvent(event) + cursor = self.textCursor() + if not cursor.hasSelection(): + block = cursor.block() + self._try_recognize_shorthand_line(block.position(), block.text()) + + def _try_recognize_shorthand_line(self, line_start: int, line_text: str) -> None: + match = _SHORTHAND_LINE_PATTERN.match(line_text) + if match is None: + return + speaker_name = match.group(1).split(":", 1)[0].strip() + character = self.app.document.get_character_by_name(speaker_name) + if character is None: + return + line_end = line_start + len(line_text) + if line_end <= line_start: + return + if self.app.document.clip_covering(line_start) is not None: + return # already tagged - don't reassign on every revisit + self._push_assign_character(line_start, line_end, character.id) + + # -- Characters menu ----------------------------------------------------- + + def contextMenuEvent(self, event) -> None: # noqa: N802 (Qt override) + menu = self._build_context_menu() + menu.exec(event.globalPos()) + + def _build_context_menu(self) -> QMenu: + """Split out from `contextMenuEvent` so tests can inspect the menu's + contents without ever calling the blocking `.exec()`.""" + menu = self.createStandardContextMenu() + menu.addSeparator() + + characters_menu = menu.addMenu("Characters") + characters = self.app.document.characters + characters_menu.setEnabled(self.textCursor().hasSelection() and bool(characters)) + for character in characters: + action = characters_menu.addAction(character.name) + action.triggered.connect(lambda checked=False, cid=character.id: self._assign_character(cid)) + + return menu + + def _assign_character(self, character_id: str) -> None: + cursor = self.textCursor() + if not cursor.hasSelection(): + return + self._push_assign_character(cursor.selectionStart(), cursor.selectionEnd(), character_id) + + # -- Copy/paste split-vs-inherit semantics ------------------------------ + + def createMimeDataFromSelection(self) -> QMimeData: # noqa: N802 (Qt override) + # A plain QMimeData, not the QTextEditMimeData super() returns: that + # private subclass reports a fixed formats() list while it still + # holds its fragment, so a custom setData() is invisible to + # hasFormat() for an in-process paste or drag. Copying the text and + # HTML over keeps ordinary paste targets working. + source = super().createMimeDataFromSelection() + mime = QMimeData() + mime.setText(source.text()) + if source.hasHtml(): + mime.setHtml(source.html()) + if not self.app.settings.get("character_fx_copy", True): + return mime + cursor = self.textCursor() + if cursor.hasSelection(): + source_clip = self.app.document.clip_covering(cursor.selectionStart()) + if source_clip is not None and source_clip.character_id: + mime.setData(self.CHARACTER_ID_MIME_TYPE, source_clip.character_id.encode("utf-8")) + return mime + + def _extract_source_character_id(self, source: QMimeData) -> Optional[str]: + if not source.hasFormat(self.CHARACTER_ID_MIME_TYPE): + return None + raw = bytes(source.data(self.CHARACTER_ID_MIME_TYPE)).decode("utf-8") + return raw or None + + def insertFromMimeData(self, source: QMimeData) -> None: # noqa: N802 (Qt override) + source_character_id = self._extract_source_character_id(source) + cursor = self.textCursor() + insert_position = cursor.selectionStart() if cursor.hasSelection() else cursor.position() + + self._paste_chars_accumulator = 0 + try: + super().insertFromMimeData(source) + finally: + chars_added = self._paste_chars_accumulator + self._paste_chars_accumulator = None + + splits_enabled = self.app.settings.get("character_fx_paste_splits", True) + if source_character_id and splits_enabled and chars_added: + self._push_assign_character(insert_position, insert_position + chars_added, source_character_id) diff --git a/kokoro_gui/qt/undo_coordinator.py b/kokoro_gui/qt/undo_coordinator.py new file mode 100644 index 0000000..10455c5 --- /dev/null +++ b/kokoro_gui/qt/undo_coordinator.py @@ -0,0 +1,84 @@ +"""Coordinates undo/redo between two independent histories, per +Claude/PLAN_text_editor_redesign.md's undo-granularity grill answer: the +live `QTextDocument`'s own native undo (ordinary typing - Qt already +coalesces keystrokes like a word processor, for free) and the DAW +`Document`'s custom `UndoStack` (kokoro_gui/daw/undo.py - character/FX +assignment, clip moves, FX overrides). The two stacks are deliberately kept +separate rather than merged into one - this class is the "coordinated" half +of that choice: Ctrl+Z/Ctrl+Shift+Z, wherever triggered from (the transcript +editor's own keyPressEvent, or the app-wide Undo/Redo menu actions), pop +whichever history has the more recent action, using an interleaved +chronological order log rather than merging the histories themselves. + +Why a log instead of just comparing `isUndoAvailable()`/`can_undo()`: those +only say "is there *anything* to undo" on each side, not "which side's +top-of-stack action happened most recently" - the log is what answers that. +Redo doesn't need the log at all (see `redo()`'s docstring) - only one side +can ever have redo available at a time, since a fresh push on either stack +clears the OTHER stack's redo too (`_on_native_command_added`/ +`_on_custom_command_pushed` below), matching ordinary "a new action makes +the undone-and-abandoned branch unreachable" undo-stack semantics extended +across two stacks instead of one. +""" +from __future__ import annotations + +from typing import Callable + +from PySide6.QtGui import QTextDocument + + +class UndoCoordinator: + def __init__(self, qt_text_document: QTextDocument, daw_undo_stack, on_custom_stack_changed: Callable[[], None]): + self._qt_text_document = qt_text_document + self._daw_undo_stack = daw_undo_stack + # Called after every custom-stack undo/redo (never after a native + # one - a native text undo/redo already re-syncs itself through the + # editor's ordinary contentsChange handling) so the caller can + # repaint the live editor's QTextCharFormat runs to match whatever + # app.document.runs now says, and refresh save/timeline state. + self._on_custom_stack_changed = on_custom_stack_changed + + self._order: list = [] # "native" | "custom", most-recent last + + qt_text_document.undoCommandAdded.connect(self._on_native_command_added) + daw_undo_stack.on_push = self._on_custom_command_pushed + + def _on_native_command_added(self) -> None: + self._order.append("native") + self._daw_undo_stack.clear_redo() + + def _on_custom_command_pushed(self) -> None: + self._order.append("custom") + self._qt_text_document.clearUndoRedoStacks(QTextDocument.Stacks.RedoStack) + + def can_undo(self) -> bool: + return bool(self._order) + + def can_redo(self) -> bool: + return self._daw_undo_stack.can_redo() or self._qt_text_document.isRedoAvailable() + + def undo(self) -> None: + """No-op if there's nothing to undo on either side.""" + if not self._order: + return + kind = self._order.pop() + if kind == "native": + self._qt_text_document.undo() + else: + self._daw_undo_stack.undo() + self._on_custom_stack_changed() + + def redo(self) -> None: + """Resumes whichever side currently has redo available - at most one + ever does (see the module docstring), so there's no ambiguity to + resolve via the order log the way `undo()` needs it. Redoing pushes + a fresh entry back onto the order log, same as an original action - + it's "the most recent thing that happened" again, for the next + undo.""" + if self._daw_undo_stack.can_redo(): + self._daw_undo_stack.redo() + self._order.append("custom") + self._on_custom_stack_changed() + elif self._qt_text_document.isRedoAvailable(): + self._qt_text_document.redo() + self._order.append("native") diff --git a/kokoro_gui/qt/waveform_data.py b/kokoro_gui/qt/waveform_data.py new file mode 100644 index 0000000..3b871ae --- /dev/null +++ b/kokoro_gui/qt/waveform_data.py @@ -0,0 +1,78 @@ +"""Min/max peak decimation for the waveform-view spike +(Workstream 3 of Claude/PLAN_daw_ui_ux_redesign.md). + +Lives under `kokoro_gui/qt/` rather than `kokoro_gui/engine/`: this is +presentation-layer decimation for one specific widget (waveform_view.py), +not a general engine capability - even though the module itself has zero Qt +imports and is plain NumPy. + +This is a spike-scoped module. Two simplifications are called out explicitly +rather than hidden: + +- Stereo input is downmixed by a plain per-frame average across channels. + This phase-cancels a hypothetical inverted-phase stereo signal into + apparent silence - acceptable for a spike, worth revisiting only if it + ever matters in practice. +- `compute_peaks` always recomputes from raw samples; there's no + multi-resolution/mipmap cache. Fine for clip-length audio at any bucket + count reachable from a real window size - a full-build pass should + benchmark real multi-minute files before deciding whether that's needed. +""" +import numpy as np +import soundfile as sf + + +def compute_peaks(samples: np.ndarray, sample_rate: int, bucket_count: int) -> np.ndarray: + """Returns a `(bucket_count, 2)` float32 array of `(min, max)` pairs - + one pair per horizontal "pixel bucket", the standard technique for + rendering a waveform without holding/redrawing every raw sample. + + `samples` may be 1-D (mono) or 2-D `(n, channels)` (stereo/multi-channel, + downmixed to mono via a plain average across channels - see module + docstring). `sample_rate` is accepted for interface symmetry with + `load_peaks_from_file` but unused here - decimation only cares about + sample *count*, not real time; duration is the caller's concern. + + Bucket count vs. sample count: + - `bucket_count <= len(samples)`: samples are split into `bucket_count` + near-equal groups via `np.array_split` (NumPy's own "distribute N + items into K groups" rule), each group reduced to its own min/max. + - `bucket_count > len(samples)` (more pixels than samples - a short + clip in a wide view): bucket `i` for `i < len(samples)` gets + `(samples[i], samples[i])` (a single real sample, min == max); + buckets beyond that stay `(0.0, 0.0)`. Deliberately not carried + forward from the last real sample - that would visually fabricate a + continuous waveform out of a handful of real data points. + + All-silence and empty (`len(samples) == 0`) inputs both fall out to an + all-zero result without any special-casing. + """ + if samples.ndim == 2: + samples = samples.mean(axis=1) + samples = np.asarray(samples, dtype=np.float32) + + peaks = np.zeros((bucket_count, 2), dtype=np.float32) + if samples.size == 0 or bucket_count <= 0: + return peaks + + if bucket_count <= samples.size: + for i, bucket in enumerate(np.array_split(samples, bucket_count)): + peaks[i, 0] = bucket.min() + peaks[i, 1] = bucket.max() + else: + n = samples.size + peaks[:n, 0] = samples + peaks[:n, 1] = samples + + return peaks + + +def load_peaks_from_file(path: str, bucket_count: int): + """Reads `path` via `soundfile` (same `sf.read(..., dtype='float32')` + call pattern `playback.py`/`kokoro_gui/engine/caching.py` already use), + and returns `(compute_peaks(...), duration_seconds)`. File I/O is kept + separate from `compute_peaks`'s pure array math so that function stays + testable with in-memory arrays and no temp files.""" + data, sample_rate = sf.read(path, dtype="float32") + duration = len(data) / sample_rate if sample_rate else 0.0 + return compute_peaks(data, sample_rate, bucket_count), duration diff --git a/kokoro_gui/qt/waveform_view.py b/kokoro_gui/qt/waveform_view.py new file mode 100644 index 0000000..36d2f3b --- /dev/null +++ b/kokoro_gui/qt/waveform_view.py @@ -0,0 +1,116 @@ +"""Waveform rendering primitives: `WaveformItem` (the peak-envelope path the +timeline draws inside every generated clip block) and `WaveformView`, the +single-file view the original Workstream 3 spike validated. + +The spike's `WaveformPanel` (Play/Stop plus a wall-clock playhead) and +`playhead_calc.py` are gone - the real transport +(`kokoro_gui.audio.transport.Transport`) tracks position from the audio +callback's frame counter, and the timeline draws the playhead. +""" +from __future__ import annotations + +from PySide6.QtCore import QRectF +from PySide6.QtGui import QBrush, QColor, QPainterPath +from PySide6.QtWidgets import QGraphicsItem, QGraphicsScene, QGraphicsView + +from kokoro_gui.qt import waveform_data + +WAVEFORM_BRUSH_COLOR = "#4a90d9" + + +class WaveformItem(QGraphicsItem): + """Paints a (min, max) peak envelope as a filled path. The path is built + once per `set_peaks()` call and cached - `paint()` only ever strokes/ + fills that cached path, never rebuilds it, since `paint()` fires on + every scene repaint (including ones triggered by an unrelated sibling + item like the playhead moving) and rebuilding a path across potentially + thousands of buckets on every such repaint would be a real perf bug. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._width = 0.0 + self._height = 0.0 + self._peaks = None + self._path = QPainterPath() + self._color = WAVEFORM_BRUSH_COLOR + + def set_peaks(self, peaks, width: float, height: float) -> None: + # prepareGeometryChange() must happen *before* the stored width/ + # height change - otherwise Qt's dirty-tracking can paint against a + # now-stale boundingRect(), a classic source of clipped/ghosted + # repaints after a resize. + self.prepareGeometryChange() + self._peaks = peaks + self._width = width + self._height = height + self._path = self._build_path(peaks, width, height) + self.update() + + @staticmethod + def _build_path(peaks, width: float, height: float) -> QPainterPath: + path = QPainterPath() + n = len(peaks) if peaks is not None else 0 + if n == 0 or width <= 0 or height <= 0: + return path + + half_height = height / 2.0 + bucket_width = width / n + for i, (lo, hi) in enumerate(peaks): + x = i * bucket_width + y_top = half_height - hi * half_height + y_bottom = half_height - lo * half_height + path.addRect(x, y_top, bucket_width, max(y_bottom - y_top, 0.0)) + return path + + def boundingRect(self) -> QRectF: # noqa: N802 (Qt override) + return QRectF(0, 0, self._width, self._height) + + def set_color(self, hex_color: str) -> None: + self._color = hex_color + self.update() + + def paint(self, painter, option, widget=None) -> None: # noqa: N802 (Qt override) + painter.fillPath(self._path, QBrush(QColor(self._color))) + + +class WaveformView(QGraphicsView): + """Owns a `QGraphicsScene` with one `WaveformItem`. `load_audio()` reads + a WAV via `waveform_data.load_peaks_from_file` at one bucket per + horizontal pixel and feeds the result to the item; resizing recomputes + peaks at the new bucket count (recompute-on-resize, not a cached + multi-resolution pyramid - see waveform_data.py's module docstring for + why that's an accepted spike-scoped simplification).""" + + def __init__(self, parent=None): + super().__init__(parent) + self._scene = QGraphicsScene(self) + self.setScene(self._scene) + self.waveform_item = WaveformItem() + self._scene.addItem(self.waveform_item) + + self._loaded_path: str | None = None + self.duration: float = 0.0 + + def load_audio(self, path: str) -> None: + self._loaded_path = path + self._reload_peaks() + + def _current_bucket_count(self) -> int: + return max(1, self.viewport().width()) + + def _reload_peaks(self) -> None: + if not self._loaded_path: + return + bucket_count = self._current_bucket_count() + peaks, duration = waveform_data.load_peaks_from_file(self._loaded_path, bucket_count) + self.duration = duration + + width = max(1, self.viewport().width()) + height = max(1, self.viewport().height()) + self.waveform_item.set_peaks(peaks, width, height) + self._scene.setSceneRect(0, 0, width, height) + + def resizeEvent(self, event) -> None: # noqa: N802 (Qt override) + super().resizeEvent(event) + self._reload_peaks() diff --git a/kokoro_gui/qt/welcome_dialog.py b/kokoro_gui/qt/welcome_dialog.py new file mode 100644 index 0000000..c183a07 --- /dev/null +++ b/kokoro_gui/qt/welcome_dialog.py @@ -0,0 +1,214 @@ +"""Welcome dialog (grill WF2, revised): recent projects, Resume, New, New +from text, Open, shown over the main window on launch. + +The window has already loaded the last project by the time this opens, so +the engine warms up underneath, Escape is a free Resume, and New inherits +the loaded project's characters (WF3) the same way File > New does. Every +pick is a one-line call into `QtTTSApp` (`open_project`, `new_project`, +`import_text(..., target="new")`, `open_project_dialog`); nothing here +touches the document directly. + +Opened with `open()` rather than `exec()`: window-modal but asynchronous, +so the engine's init status still reaches the transport dock and pytest-qt +can drive the dialog without a blocked event loop. `main.py` is the only +launch-time trigger (`QtTTSApp.show_welcome_if_enabled`); the test fixture +and `scripts/render_screenshot.py` never see it. +""" +from __future__ import annotations + +import os + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QCheckBox, QDialog, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QListWidget, + QListWidgetItem, QMenu, QPushButton, QVBoxLayout, +) + +from kokoro_gui.qt import project as project_io + +MISSING_SUFFIX = " (missing)" +TEXT_FILTER = "Documents (*.txt *.pdf *.epub)" + + +def _format_duration(seconds: float) -> str: + seconds = int(round(seconds or 0)) + hours, rest = divmod(seconds, 3600) + minutes, secs = divmod(rest, 60) + return f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes}:{secs:02d}" + + +class WelcomeDialog(QDialog): + def __init__(self, app, parent=None): + super().__init__(parent or app) + self.app = app + self.setWindowTitle("Welcome") + self.resize(720, 400) + + root = QVBoxLayout(self) + body = QHBoxLayout() + root.addLayout(body, 1) + + left = QVBoxLayout() + left.addWidget(QLabel("Recent projects")) + self.list = QListWidget() + self.list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.list.customContextMenuRequested.connect(self._show_row_menu) + self.list.currentItemChanged.connect(self._on_current_changed) + self.list.itemActivated.connect(lambda item: self.choose(item.data(Qt.ItemDataRole.UserRole))) + left.addWidget(self.list, 1) + self.clear_btn = QPushButton("Clear list") + self.clear_btn.clicked.connect(self.clear_recent) + left.addWidget(self.clear_btn, 0, Qt.AlignmentFlag.AlignLeft) + body.addLayout(left, 3) + + right = QVBoxLayout() + details = QGroupBox("Details") + form = QFormLayout(details) + self.path_label = QLabel("-") + self.path_label.setWordWrap(True) + self.modified_label = QLabel("-") + self.characters_label = QLabel("-") + self.clips_label = QLabel("-") + self.duration_label = QLabel("-") + self.engines_label = QLabel("-") + form.addRow("Path:", self.path_label) + form.addRow("Modified:", self.modified_label) + form.addRow("Characters:", self.characters_label) + form.addRow("Clips:", self.clips_label) + form.addRow("Audio:", self.duration_label) + form.addRow("Engines:", self.engines_label) + right.addWidget(details) + + self.open_btn = QPushButton("Open") + self.open_btn.setDefault(True) + self.open_btn.clicked.connect(self._open_selected) + self.new_btn = QPushButton("New project") + self.new_btn.clicked.connect(self.new_project) + self.new_from_text_btn = QPushButton("New from text file...") + self.new_from_text_btn.clicked.connect(self._new_from_text_dialog) + self.open_other_btn = QPushButton("Open other...") + self.open_other_btn.clicked.connect(self.open_other) + for btn in (self.open_btn, self.new_btn, self.new_from_text_btn, self.open_other_btn): + right.addWidget(btn) + right.addStretch(1) + body.addLayout(right, 2) + + self.show_at_startup = QCheckBox("Show at startup") + self.show_at_startup.setChecked(bool(app.settings.get("show_welcome", True))) + self.show_at_startup.toggled.connect(self._on_show_toggled) + root.addWidget(self.show_at_startup, 0, Qt.AlignmentFlag.AlignLeft) + + self.reload() + + # --- list ------------------------------------------------------------------ + + def reload(self) -> None: + """Rebuilds the rows from `settings["recent_projects"]`; the open + project is listed first and starts selected.""" + self.show_at_startup.setChecked(bool(self.app.settings.get("show_welcome", True))) + self.list.clear() + current = self.app.project_path + recent = [p for p in self.app.settings.get("recent_projects", []) if isinstance(p, str)] + if current: + recent = [current] + [p for p in recent if os.path.abspath(p) != os.path.abspath(current)] + for path in recent: + item = QListWidgetItem(project_io.project_title(path)) + item.setData(Qt.ItemDataRole.UserRole, path) + item.setToolTip(path) + if not os.path.isfile(path): + item.setText(item.text() + MISSING_SUFFIX) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEnabled) + self.list.addItem(item) + first_enabled = next((i for i in range(self.list.count()) + if self.list.item(i).flags() & Qt.ItemFlag.ItemIsEnabled), None) + if first_enabled is not None: + self.list.setCurrentRow(first_enabled) + else: + self.list.setCurrentRow(-1) + self._on_current_changed(None, None) + + def paths(self) -> list: + return [self.list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.list.count())] + + def selected_path(self) -> str | None: + item = self.list.currentItem() + return item.data(Qt.ItemDataRole.UserRole) if item is not None else None + + def _on_current_changed(self, current, _previous) -> None: + path = current.data(Qt.ItemDataRole.UserRole) if current is not None else None + summary = project_io.project_summary(path) if path else None + if summary is None: + self.path_label.setText(path or "-") + for label in (self.modified_label, self.characters_label, self.clips_label, + self.duration_label, self.engines_label): + label.setText("-") + else: + self.path_label.setText(summary["path"]) + self.modified_label.setText(summary["modified"].strftime("%Y-%m-%d %H:%M")) + self.characters_label.setText(str(summary["characters"])) + self.clips_label.setText(str(summary["clips"])) + duration = summary.get("duration_s") + self.duration_label.setText(_format_duration(duration) if duration is not None else "-") + engines = summary.get("engines") or [] + self.engines_label.setText(", ".join(engines) if engines else "-") + is_current = bool(path) and self.app.project_path is not None and \ + os.path.abspath(path) == os.path.abspath(self.app.project_path) + self.open_btn.setText("Resume" if is_current else "Open") + self.open_btn.setEnabled(bool(path) and (is_current or summary is not None)) + + def _show_row_menu(self, pos) -> None: + item = self.list.itemAt(pos) + if item is None: + return + menu = QMenu(self) + remove = menu.addAction("Remove from list") + remove.triggered.connect(lambda: self.remove_from_recent(item.data(Qt.ItemDataRole.UserRole))) + menu.exec(self.list.mapToGlobal(pos)) + + def remove_from_recent(self, path: str) -> None: + project_io.forget_recent(self.app.settings, path) + self.app.schedule_save() + self.app._rebuild_recent_menu() + self.reload() + + def clear_recent(self) -> None: + project_io.clear_recent(self.app.settings) + self.app.schedule_save() + self.app._rebuild_recent_menu() + self.reload() + + # --- picks ------------------------------------------------------------------- + + def _open_selected(self) -> None: + path = self.selected_path() + if path: + self.choose(path) + + def choose(self, path: str) -> None: + """Open `path`, or just close when it's the project already loaded.""" + self.accept() + current = self.app.project_path + if current and os.path.abspath(path) == os.path.abspath(current): + return + self.app.open_project(path) + + def new_project(self) -> None: + self.accept() + self.app.new_project() + + def _new_from_text_dialog(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "New project from text", filter=TEXT_FILTER) + if path: + self.new_from_text(path) + + def new_from_text(self, path: str) -> None: + self.accept() + self.app.import_text(path, target="new") + + def open_other(self) -> None: + self.accept() + self.app.open_project_dialog() + + def _on_show_toggled(self, checked: bool) -> None: + self.app.settings["show_welcome"] = bool(checked) + self.app.schedule_save() diff --git a/kokoro_gui/qt/workspace.py b/kokoro_gui/qt/workspace.py new file mode 100644 index 0000000..afeda87 --- /dev/null +++ b/kokoro_gui/qt/workspace.py @@ -0,0 +1,108 @@ +"""Named dock layouts ("workspaces") for the Qt shell - UI1/UI8 of +Claude/PLAN_ui_shell_redesign.md. + +`config_qt.json` holds `"workspaces": {name: {"state": b64, "geometry": +b64}}` plus `"active_workspace"`. Two names have programmatic defaults: + +- Advanced: the drawing's 2x2 grid (transcript | settings tabs over + timeline | transport). Built by `QtTTSApp.arrange_docks_default()`. +- Simple: the same grid with the timeline dock hidden, so the transcript + takes the whole left column. Layout only - same document, same Generate + behavior (UI8). + +Choosing a workspace restores its saved state if the user has ever dragged +something while it was active, else the programmatic default. Reset +rebuilds the default for the active one and forgets the saved edits. The +app's existing debounced autosave calls `capture()` so drag edits land in +the active entry. + +The pre-workspace `dock_state`/`geometry` keys migrate into +`workspaces.Advanced` the first time this loads them, then get dropped. +""" +from __future__ import annotations + +from kokoro_gui.qt import settings as qt_settings + +ADVANCED = "Advanced" +SIMPLE = "Simple" +WORKSPACE_NAMES = (ADVANCED, SIMPLE) + + +class WorkspaceManager: + def __init__(self, window, settings: dict): + self._window = window + self._settings = settings + self._migrate_legacy_keys() + self._settings.setdefault("workspaces", {}) + if self._settings.get("active_workspace") not in WORKSPACE_NAMES: + self._settings["active_workspace"] = ADVANCED + + # -- persistence shape ------------------------------------------------- + + def _migrate_legacy_keys(self) -> None: + legacy_state = self._settings.pop("dock_state", None) + legacy_geometry = self._settings.pop("geometry", None) + if not legacy_state and not legacy_geometry: + return + workspaces = self._settings.setdefault("workspaces", {}) + if ADVANCED not in workspaces: + workspaces[ADVANCED] = {"state": legacy_state, "geometry": legacy_geometry} + + @property + def active(self) -> str: + return self._settings.get("active_workspace", ADVANCED) + + def saved(self, name: str) -> dict | None: + entry = self._settings.get("workspaces", {}).get(name) + return entry if isinstance(entry, dict) else None + + # -- apply / capture --------------------------------------------------- + + def restore_on_launch(self) -> None: + """Called once after the docks exist: geometry from the active + entry, then the layout (saved or default).""" + entry = self.saved(self.active) + if entry and entry.get("geometry"): + try: + self._window.restoreGeometry(qt_settings.decode_bytes(entry["geometry"])) + except Exception: + pass + self.activate(self.active, save_outgoing=False) + + def activate(self, name: str, save_outgoing: bool = True) -> None: + if name not in WORKSPACE_NAMES: + name = ADVANCED + if save_outgoing: + self.capture() + self._settings["active_workspace"] = name + entry = self.saved(name) + restored = False + if entry and entry.get("state"): + try: + restored = bool(self._window.restoreState(qt_settings.decode_bytes(entry["state"]))) + except Exception: + restored = False + if not restored: + self.apply_default(name) + + def apply_default(self, name: str) -> None: + self._window.arrange_docks_default() + timeline = getattr(self._window, "timeline_dock", None) + if timeline is not None: + timeline.setVisible(name != SIMPLE) + if name == SIMPLE and hasattr(self._window, "apply_simple_proportions"): + self._window.apply_simple_proportions() + + def reset(self) -> None: + """Forget the active workspace's saved edits and rebuild its + programmatic default.""" + self._settings.setdefault("workspaces", {}).pop(self.active, None) + self.apply_default(self.active) + + def capture(self) -> None: + """Snapshot the live layout into the active entry.""" + workspaces = self._settings.setdefault("workspaces", {}) + workspaces[self.active] = { + "state": qt_settings.encode_bytes(self._window.saveState()), + "geometry": qt_settings.encode_bytes(self._window.saveGeometry()), + } diff --git a/main.py b/main.py index 61b9438..0d0afc4 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,16 @@ -import gui +"""Entry point for the PySide6 (Qt) frontend - the sole GUI frontend since the +Tk frontend (gui.py) was retired (see PLAN_qt_and_engine_abstraction.md, +workstream 3a). PySide6 is a regular dependency in `requirements.txt`. +""" +import sys + +from PySide6.QtWidgets import QApplication + +from kokoro_gui.qt.app import QtTTSApp if __name__ == "__main__": - app = gui.TTSApp() - app.mainloop() \ No newline at end of file + app = QApplication(sys.argv) + window = QtTTSApp() + window.show() + window.show_welcome_if_enabled() + sys.exit(app.exec()) diff --git a/pytest.ini b/pytest.ini index 0931d05..667cf5a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,4 +2,5 @@ testpaths = tests markers = integration: real KPipeline/torch/espeak-ng synthesis tests (slow, skipped by default) -addopts = -m "not integration" --strict-markers + slow: opt-in tests that need minutes or gigabytes (the >4 GB bundle round trip); run with -m slow +addopts = -m "not integration and not slow" --strict-markers diff --git a/requirements-test.txt b/requirements-test.txt index 039d26e..713af92 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1 +1,2 @@ pytest>=8.0 +pytest-qt==4.5.0 diff --git a/requirements.txt b/requirements.txt index ea0d45b..6bfb95b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,12 +2,19 @@ beautifulsoup4==4.14.3 EbookLib==0.20 kokoro==0.9.4 numpy==2.4.1 -pypdf==6.15.0 +pypdf==6.16.1 packaging scipy -pedalboard +# 0.9.24 and 0.9.25 Linux wheels are built with -march=native and die with SIGILL +# on AMD Zen 3 (GitHub's ubuntu runners): spotify/pedalboard#454. 0.9.23 imports there. +pedalboard==0.9.23 soundfile==0.13.1 sounddevice torch==2.13.0 -customtkinter -packaging \ No newline at end of file +PySide6==6.11.2 +transformers==4.57.6 +torchaudio>=2.5.0 +safetensors>=0.4 +librosa==0.11.0 +vosk +python-dotenv \ No newline at end of file diff --git a/scripts/render_screenshot.py b/scripts/render_screenshot.py new file mode 100644 index 0000000..7801064 --- /dev/null +++ b/scripts/render_screenshot.py @@ -0,0 +1,143 @@ +"""Render the Qt shell to a PNG without a display or a model. + + python scripts/render_screenshot.py out.png [--theme dark] [--workspace Simple] + [--size 1600x1000] + +Builds a `QtTTSApp` against `tests.conftest.StubEngine` (no Kokoro, no +eSpeak, no audio device) in a temp working directory, loads a small sample +project with three characters, marks two clips as generated with synthetic +audio so the timeline shows waveforms next to estimated clips, and grabs +the window. Used to compare each step of Claude/PLAN_ui_shell_redesign.md +against the wireframe, and to refresh the README/docs screenshots. +""" +from __future__ import annotations + +import argparse +import os +import sys +import tempfile + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +if sys.platform == "win32": + os.environ.setdefault("QT_QPA_FONTDIR", r"C:\Windows\Fonts") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) + +import numpy as np # noqa: E402 +import soundfile as sf # noqa: E402 + +SAMPLE_TEXT = ( + "Sample text to demonstrate the transcript panel. It highlights each character's lines.\n" + "Even highlights multiple sentences when they belong to the same clip.\n" + "Charly answers here, with an echo on the voice.\n" + "The narrator picks the story back up and carries it to the end of the page." +) + + +def _write_tone(path: str, seconds: float, freq: float, rate: int = 24000) -> None: + t = np.linspace(0, seconds, int(rate * seconds), endpoint=False) + env = 0.5 + 0.5 * np.sin(2 * np.pi * 0.7 * t) + data = (0.4 * np.sin(2 * np.pi * freq * t) * env).astype(np.float32) + sf.write(path, data, rate) + + +def build_app(workdir: str, theme_name: str, workspace: str): + from tests.conftest import StubEngine # noqa: E402 + + import kokoro_gui.qt.app as qt_app_module # noqa: E402 + from PySide6.QtWidgets import QApplication # noqa: E402 + + from kokoro_gui.daw.dirty import build_segments_from_results, compute_expected_cache_hash # noqa: E402 + from kokoro_gui.daw.models import Character, Track # noqa: E402 + + os.chdir(workdir) + qt_app_module.CONFIG_FILE = os.path.join(workdir, "config_qt.json") + qt_app_module.PRESETS_DIR = os.path.join(workdir, "presets") + qt_app_module.FX_PRESETS_DIR = os.path.join(workdir, "presets", "fx") + qt_app_module.DOCUMENT_FILE = os.path.join(workdir, "document.json") + qt_app_module.KokoroEngine = StubEngine + os.makedirs(qt_app_module.FX_PRESETS_DIR, exist_ok=True) + with open(os.path.join(qt_app_module.FX_PRESETS_DIR, "Echo.json"), "w", encoding="utf-8") as f: + f.write('{"delay_enabled": true, "delay_time": 0.3, "delay_feedback": 0.3, "delay_mix": 0.4}') + + qapp = QApplication.instance() or QApplication(sys.argv) + from kokoro_gui.qt import settings as qt_settings # noqa: E402 + + settings = qt_settings.load_settings(qt_app_module.CONFIG_FILE) + settings["theme"] = theme_name + settings["active_workspace"] = workspace + qt_settings.save_settings(qt_app_module.CONFIG_FILE, settings) + + app = qt_app_module.QtTTSApp() + doc = app.document + doc.characters = [ + Character.from_preset_dict("Narrator", {"voice": "af_heart"}, highlight_color="#f4b400"), + Character.from_preset_dict("Charly", {"voice": "am_michael", "fx_preset": "Echo"}, highlight_color="#4285f4"), + Character.from_preset_dict("Ada", {"voice": "bf_emma"}, highlight_color="#0f9d58"), + ] + doc.tracks = [Track(name=c.name, character_id=c.id, order_index=i) for i, c in enumerate(doc.characters)] + narrator, charly, ada = doc.characters + + app.editor.load_text(SAMPLE_TEXT) + doc.set_plain_text(SAMPLE_TEXT) + lines = SAMPLE_TEXT.split("\n") + offsets = [] + pos = 0 + for line in lines: + offsets.append((pos, pos + len(line))) + pos += len(line) + 1 + c1 = doc.assign_character_to_range(offsets[0][0], offsets[1][1], narrator.id) + c2 = doc.assign_character_to_range(offsets[2][0], offsets[2][1], charly.id) + c3 = doc.assign_character_to_range(offsets[3][0], offsets[3][1], ada.id) + + # Two generated clips (waveforms), one still estimated (dashed). + audio_dir = os.path.join(workdir, "audio") + os.makedirs(audio_dir, exist_ok=True) + for clip, seconds, freq in ((c1, 6.5, 220.0), (c2, 3.2, 330.0)): + path = os.path.join(audio_dir, f"{clip.id}.wav") + _write_tone(path, seconds, freq) + text = doc.clip_text(clip) + config = app._assemble_clip_config(clip) + expected = compute_expected_cache_hash(text, config) + clip.segments = build_segments_from_results(expected, [{"text": text, "path": path, "duration": seconds}]) + del c3 + + app.editor.rehighlight() + app.transcript_dock.refresh_character_choices() + app.refresh_timeline() + app._rebuild_transport_schedule() + app.transport.seek(4.2) + app._on_transport_position(4.2) + return qapp, app + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("out") + parser.add_argument("--theme", default="light", choices=("light", "dark")) + parser.add_argument("--workspace", default="Advanced", choices=("Advanced", "Simple")) + parser.add_argument("--size", default="1600x1000") + args = parser.parse_args() + width, height = (int(v) for v in args.size.lower().split("x")) + + out = os.path.abspath(args.out) + workdir = tempfile.mkdtemp(prefix="kokorogui_shot_") + qapp, app = build_app(workdir, args.theme, args.workspace) + app.resize(width, height) + app.workspaces.apply_default(args.workspace) + app.show() + for _ in range(3): # let the deferred showEvent proportion pass run + qapp.processEvents() + app.timeline_dock.timeline_view.set_playhead(4.2) + qapp.processEvents() + app.grab().save(out) + print(out) + # The sample project is Untitled and edited: closing would ask Save / + # Discard / Cancel (grill TB12), which a headless run can't answer. + app._ask_close_choice = lambda: "discard" + app.close() + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py index 712fbd3..9637467 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,6 @@ """ import os import re -import sys import time import threading import concurrent.futures @@ -25,20 +24,8 @@ import kokoro_engine from kokoro_engine import KokoroEngine -# On some Windows Store ("WindowsApps") Python installs, Tcl/Tk's own -# init.tcl discovery intermittently fails against the package-virtualized -# path when many Tk() roots are created/destroyed across a test session -# (each GUI test builds a real TTSApp). Pointing TCL_LIBRARY/TK_LIBRARY at -# the known-good path once avoids repeated, occasionally-flaky rediscovery. -_tcl_dir = os.path.join(sys.base_prefix, "tcl", "tcl8.6") -_tk_dir = os.path.join(sys.base_prefix, "tcl", "tk8.6") -if os.path.isdir(_tcl_dir): - os.environ.setdefault("TCL_LIBRARY", _tcl_dir) -if os.path.isdir(_tk_dir): - os.environ.setdefault("TK_LIBRARY", _tk_dir) - -# One shared timestamp per pytest invocation, mirroring gui.py's -# self.timecode_format = "%Y%m%d%H%M%S" convention (gui.py:96). +# One shared timestamp per pytest invocation, mirroring the Qt frontend's +# "%Y%m%d%H%M%S" timecode convention (kokoro_gui/qt/app.py). _RUN_TS = time.strftime("%Y%m%d%H%M%S") @@ -56,6 +43,11 @@ def isolated_dirs(tmp_path, monkeypatch): d.mkdir() monkeypatch.setattr(kokoro_engine, "CUSTOM_VOICES_DIR", str(custom_voices)) monkeypatch.setattr(kokoro_engine, "CACHE_DIR", str(cache_dir)) + # generation_stats.py reads/writes this qualified through kokoro_engine + # (same convention as CACHE_DIR above) - redirect it too, or every real + # _process_text_async run in the suite would write a real + # generation_stats.json into the repo working directory. + monkeypatch.setattr(kokoro_engine, "STATS_FILE", str(tmp_path / "generation_stats.json")) return SimpleNamespace(custom_voices=custom_voices, cache_dir=cache_dir, out_dir=out_dir) @@ -182,14 +174,37 @@ def espeak_available(): return False +_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def strip_ansi(text: str) -> str: + """Strips ANSI color/reset escape sequences from `text`. Some + subprocess-spawning tests (test_asr.py/test_engines_audio8.py's + `test_importing_module_does_not_load_the_model`) assert an exact match on + a child process's captured stdout; under some runners (e.g. PyCharm's + test runner, which sets env vars that make libraries in the import chain + think they're attached to a color-capable console) a trailing `\x1b[0m` + reset code leaks into that output even though nothing in the actual + assertion cares about color. Plain terminal/CI runs don't hit this, so + it's invisible there - this just makes the assertion robust either way.""" + return _ANSI_ESCAPE_RE.sub("", text) + + # --------------------------------------------------------------------------- # GUI-level fixtures # --------------------------------------------------------------------------- +# +# The Tk frontend (gui.py, kokoro_gui/ui/) has been retired now that the Qt +# frontend (kokoro_gui/qt/) reached parity - see PLAN_qt_and_engine_abstraction.md. +# StubEngine stays here (not moved into tests/gui_qt/) because it's imported +# by tests/gui_qt/conftest.py's `qt_app` fixture too. class StubEngine: """Drop-in replacement for KokoroEngine used by GUI tests - never touches the real Kokoro pipeline/model.""" + id = "kokoro" + def __init__(self): self.pipeline = object() # truthy - passes the "engine still initializing" gate self.worker = SimpleNamespace(run_coro=MagicMock(return_value=concurrent.futures.Future())) @@ -201,39 +216,28 @@ def __init__(self): self.start_conversion = MagicMock() self.start_jit_conversion = MagicMock() self.generate_preview = MagicMock() + self.generate_clip_audio = MagicMock() + self.generate_dirty_clips = MagicMock() self.mix_voices = MagicMock() self.extract_text_from_file = MagicMock(return_value="") + self.load_fx_preset = MagicMock(return_value=None) self.cancel = MagicMock() + # The segment-key trio CachingMixin gives real engines + # (kokoro_gui/engine/caching.py): the adapter forwards to these. + def engine_version(self): + from kokoro_gui.engine.caching import get_engine_version -@pytest.fixture -def tts_app(tmp_path, monkeypatch): - import gui - import tkinter - - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(gui, "CONFIG_FILE", str(tmp_path / "config.json")) - monkeypatch.setattr(gui, "PRESETS_DIR", str(tmp_path / "presets")) - monkeypatch.setattr(gui, "FX_PRESETS_DIR", str(tmp_path / "presets" / "fx")) - monkeypatch.setattr(gui, "KokoroEngine", StubEngine) - monkeypatch.setattr(gui, "messagebox", MagicMock()) - monkeypatch.setattr(gui, "filedialog", MagicMock()) - (tmp_path / "custom_voices").mkdir() - - # Creating many real Tk() interpreters across a test session intermittently - # hits the same WindowsApps init.tcl read glitch as above - retry a few - # times rather than failing the whole test on a transient hiccup. - app = None - last_err = None - for _ in range(5): - try: - app = gui.TTSApp() - break - except tkinter.TclError as e: - last_err = e - time.sleep(0.2) - if app is None: - raise last_err - - yield app - app.destroy() + return get_engine_version("kokoro") + + def cache_key_extra(self, config): + return {} + + def resolve_voice_path(self, name, project_dir=None): + from kokoro_gui.engine.voices import VoiceMixingMixin + + return VoiceMixingMixin.resolve_voice_path(self, name, project_dir) + + def resolve_voice_file(self, name, project_dir=None): + resolved = self.resolve_voice_path(name, project_dir) + return resolved if os.path.isabs(resolved) and os.path.isfile(resolved) else None diff --git a/tests/daw/__init__.py b/tests/daw/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/daw/test_arrangement.py b/tests/daw/test_arrangement.py new file mode 100644 index 0000000..92a07b6 --- /dev/null +++ b/tests/daw/test_arrangement.py @@ -0,0 +1,125 @@ +"""Tests for kokoro_gui/daw/arrangement.py - where clips sit on the seconds +axis (UI9/UI11 of Claude/PLAN_ui_shell_redesign.md). Pure Python.""" +from kokoro_gui.daw.arrangement import ( + FALLBACK_CHARS_PER_SECOND, compute_arrangement, estimate_duration_s, text_order_predecessor, +) +from kokoro_gui.daw.models import Character, Clip, Document, Run, Segment, Track + + +def _doc(text, tagged, **kwargs): + runs, cursor = [], 0 + for start, end, clip in sorted(tagged, key=lambda t: t[0]): + if start > cursor: + runs.append(Run(text=text[cursor:start])) + runs.append(Run(text=text[start:end], clip_id=clip.id, kind=clip.source)) + cursor = end + if cursor < len(text): + runs.append(Run(text=text[cursor:])) + return Document(runs=runs, clips=[c for _s, _e, c in tagged], **kwargs) + + +def test_estimate_uses_rate_and_speed(): + assert estimate_duration_s("x" * 30, 1.0, 10.0) == 3.0 + assert estimate_duration_s("x" * 30, 2.0, 10.0) == 1.5 + assert estimate_duration_s(" ", 1.0, 10.0) == 0.0 + + +def test_estimate_falls_back_when_no_history(): + assert estimate_duration_s("x" * 30, 1.0, None) == 30 / FALLBACK_CHARS_PER_SECOND + assert estimate_duration_s("x" * 30, 1.0, 0.0) == 30 / FALLBACK_CHARS_PER_SECOND + + +def test_clips_are_placed_end_to_end_in_text_order_across_tracks(): + alice = Character.from_preset_dict("Alice", {}) + bob = Character.from_preset_dict("Bob", {}) + track_a = Track(name="A", character_id=alice.id, order_index=0) + track_b = Track(name="B", character_id=bob.id, order_index=1) + late = Clip(character_id=bob.id, track_id=track_b.id) + early = Clip(character_id=alice.id, track_id=track_a.id) + doc = _doc("x" * 50, [(20, 50, late), (0, 20, early)], characters=[alice, bob], tracks=[track_a, track_b]) + + arr = compute_arrangement(doc, chars_per_second=10.0) + + assert [p.clip.id for p in arr.placed] == [early.id, late.id] + assert arr.placed[0].start_s == 0.0 + assert arr.placed[0].duration_s == 2.0 + assert arr.placed[0].estimated is True + assert arr.placed[1].start_s == 2.0 + assert arr.placed[1].duration_s == 3.0 + assert arr.total_duration_s == 5.0 + + +def test_generated_clip_uses_segment_durations_and_is_not_estimated(): + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="A", character_id=alice.id) + clip = Clip(character_id=alice.id, track_id=track.id, + segments=[Segment(duration=1.5, audio_path="a.wav"), Segment(duration=0.5, audio_path="b.wav")]) + doc = _doc("x" * 100, [(0, 100, clip)], characters=[alice], tracks=[track]) + + arr = compute_arrangement(doc, chars_per_second=10.0) + + assert arr.placed[0].duration_s == 2.0 + assert arr.placed[0].estimated is False + + +def test_pinned_timestamp_overrides_sequential_placement_and_shifts_followers(): + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="A", character_id=alice.id) + first = Clip(character_id=alice.id, track_id=track.id, timeline_timestamp=4.0) + second = Clip(character_id=alice.id, track_id=track.id) + doc = _doc("x" * 20, [(0, 10, first), (10, 20, second)], characters=[alice], tracks=[track]) + + arr = compute_arrangement(doc, chars_per_second=10.0) + + assert arr.placed[0].start_s == 4.0 + assert arr.placed[1].start_s == 5.0 # follows the pinned clip's end + + +def test_effective_speed_from_character_preset_shortens_estimate(): + fast = Character.from_preset_dict("Fast", {"speed": 2.0}) + track = Track(name="F", character_id=fast.id) + clip = Clip(character_id=fast.id, track_id=track.id) + doc = _doc("x" * 20, [(0, 20, clip)], characters=[fast], tracks=[track]) + + arr = compute_arrangement(doc, chars_per_second=10.0) + + assert arr.placed[0].duration_s == 1.0 + + +def test_at_time_and_predecessor_lookups(): + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="A", character_id=alice.id) + a = Clip(character_id=alice.id, track_id=track.id) + b = Clip(character_id=alice.id, track_id=track.id) + doc = _doc("x" * 20, [(0, 10, a), (10, 20, b)], characters=[alice], tracks=[track]) + arr = compute_arrangement(doc, chars_per_second=10.0) + + assert [p.clip.id for p in arr.at_time(0.5)] == [a.id] + assert [p.clip.id for p in arr.at_time(1.5)] == [b.id] + assert arr.at_time(9.0) == [] + assert text_order_predecessor(arr, b.id).clip.id == a.id + assert text_order_predecessor(arr, a.id) is None + + +def test_clip_without_runs_is_skipped(): + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="A", character_id=alice.id) + orphan = Clip(character_id=alice.id, track_id=track.id) + doc = Document.from_plain_text("hello", characters=[alice], tracks=[track], clips=[orphan]) + + arr = compute_arrangement(doc, chars_per_second=10.0) + + assert arr.placed == [] + assert arr.total_duration_s == 0.0 + + +def test_clip_duration_callable_replaces_segment_durations(): + alice = Character.from_preset_dict("Alice", {}) + clip = Clip(character_id=alice.id, segments=[Segment(order_index=0, duration=4.0, audio_path="x.wav")]) + doc = _doc("hello", [(0, 5, clip)], characters=[alice]) + + placed = compute_arrangement(doc, chars_per_second=10.0, clip_duration=lambda c: 1.25).placed[0] + assert placed.duration_s == 1.25 and not placed.estimated + + placed = compute_arrangement(doc, chars_per_second=10.0, clip_duration=lambda c: None).placed[0] + assert placed.estimated diff --git a/tests/daw/test_assign_character.py b/tests/daw/test_assign_character.py new file mode 100644 index 0000000..06cf191 --- /dev/null +++ b/tests/daw/test_assign_character.py @@ -0,0 +1,161 @@ +"""Tests for kokoro_gui/daw/models.py's Document.assign_character_to_range +and clip_covering - the shared split-or-create primitive behind the +transcript panel's Characters menu, gutter dropdowns, and paste-splitting.""" +import pytest + +from kokoro_gui.daw.dirty import is_clip_dirty +from kokoro_gui.daw.models import Character, Document, Track + + +def _document_with_characters(): + alice = Character.from_preset_dict("Alice", {"voice": "af_bella"}) + bob = Character.from_preset_dict("Bob", {"voice": "am_michael"}) + tracks = [Track(name="Alice", character_id=alice.id), Track(name="Bob", character_id=bob.id)] + doc = Document.from_plain_text("0123456789ABCDEFGHIJ", characters=[alice, bob], tracks=tracks) + return doc, alice, bob + + +# --------------------------------------------------------------------------- +# clip_covering +# --------------------------------------------------------------------------- + +def test_clip_covering_returns_none_when_no_clip_present(): + doc, _, _ = _document_with_characters() + assert doc.clip_covering(5) is None + + +def test_clip_covering_inclusive_start_exclusive_end(): + doc, alice, _ = _document_with_characters() + clip = doc.assign_character_to_range(5, 10, alice.id) + assert doc.clip_covering(4) is not clip + assert doc.clip_covering(5) is clip + assert doc.clip_covering(9) is clip + assert doc.clip_covering(10) is not clip + + +# --------------------------------------------------------------------------- +# assign_character_to_range +# --------------------------------------------------------------------------- + +def test_raises_on_empty_or_inverted_range(): + doc, alice, _ = _document_with_characters() + with pytest.raises(ValueError): + doc.assign_character_to_range(5, 5, alice.id) + with pytest.raises(ValueError): + doc.assign_character_to_range(5, 3, alice.id) + + +def test_creates_clip_when_none_exists(): + doc, alice, _ = _document_with_characters() + clip = doc.assign_character_to_range(2, 6, alice.id) + assert clip in doc.clips + assert doc.clip_extent(clip.id) == (2, 6) + assert clip.character_id == alice.id + assert clip.track_id == next(t.id for t in doc.tracks if t.character_id == alice.id) + assert clip.segments == [] + + +def test_track_id_falls_back_to_none_without_a_matching_track(): + doc = Document.from_plain_text("hello") + clip = doc.assign_character_to_range(0, 5, "nonexistent-character") + assert clip.track_id is None + + +def test_exact_match_reassignment_creates_new_id(): + doc, alice, bob = _document_with_characters() + old_clip = doc.assign_character_to_range(0, 5, alice.id) + old_id = old_clip.id + + new_clip = doc.assign_character_to_range(0, 5, bob.id) + + assert new_clip.id != old_id + assert doc.get_clip(old_id) is None + assert new_clip.character_id == bob.id + assert len(doc.clips) == 1 + + +def test_left_only_split(): + doc, alice, bob = _document_with_characters() + existing = doc.assign_character_to_range(0, 10, alice.id) + + doc.assign_character_to_range(5, 10, bob.id) + + assert existing.id not in {c.id for c in doc.clips} + leftover = next(c for c in doc.clips if c.character_id == alice.id) + assert doc.clip_extent(leftover.id) == (0, 5) + assigned = next(c for c in doc.clips if c.character_id == bob.id) + assert doc.clip_extent(assigned.id) == (5, 10) + assert len(doc.clips) == 2 + + +def test_right_only_split(): + doc, alice, bob = _document_with_characters() + doc.assign_character_to_range(0, 10, alice.id) + + doc.assign_character_to_range(0, 5, bob.id) + + leftover = next(c for c in doc.clips if c.character_id == alice.id) + assert doc.clip_extent(leftover.id) == (5, 10) + assigned = next(c for c in doc.clips if c.character_id == bob.id) + assert doc.clip_extent(assigned.id) == (0, 5) + assert len(doc.clips) == 2 + + +def test_both_sided_split(): + doc, alice, bob = _document_with_characters() + doc.assign_character_to_range(0, 20, alice.id) + + doc.assign_character_to_range(5, 10, bob.id) + + alice_leftovers = sorted( + (c for c in doc.clips if c.character_id == alice.id), + key=lambda c: doc.clip_extent(c.id), + ) + assert len(alice_leftovers) == 2 + assert doc.clip_extent(alice_leftovers[0].id) == (0, 5) + assert doc.clip_extent(alice_leftovers[1].id) == (10, 20) + assigned = next(c for c in doc.clips if c.character_id == bob.id) + assert doc.clip_extent(assigned.id) == (5, 10) + assert len(doc.clips) == 3 + + +def test_selection_spanning_three_clips_worked_example(): + # Clip A: [0, 10) Alice, Clip B: [10, 15) Bob, Clip C: [15, 20) Alice. + doc, alice, bob = _document_with_characters() + clip_a = doc.assign_character_to_range(0, 10, alice.id) + clip_b = doc.assign_character_to_range(10, 15, bob.id) + clip_c = doc.assign_character_to_range(15, 20, alice.id) + + # Assign Bob to [5, 18) - overlaps all three. + new_clip = doc.assign_character_to_range(5, 18, bob.id) + + assert len(doc.clips) == 3 # leftover of A, leftover of C, and the new clip - B fully consumed + a_leftover = next(c for c in doc.clips if doc.clip_extent(c.id) is not None and doc.clip_extent(c.id)[0] == 0) + assert doc.clip_extent(a_leftover.id) == (0, 5) + assert a_leftover.character_id == alice.id + c_leftover = next(c for c in doc.clips if doc.clip_extent(c.id) is not None and doc.clip_extent(c.id)[1] == 20) + assert doc.clip_extent(c_leftover.id) == (18, 20) + assert c_leftover.character_id == alice.id + assert doc.clip_extent(new_clip.id) == (5, 18) + assert new_clip.character_id == bob.id + assert clip_a.id not in {c.id for c in doc.clips} + assert clip_b.id not in {c.id for c in doc.clips} + assert clip_c.id not in {c.id for c in doc.clips} + + +def test_leftover_clips_are_dirty_and_have_fresh_ids(): + doc, alice, bob = _document_with_characters() + original = doc.assign_character_to_range(0, 10, alice.id) + original.segments = ["pretend-generated"] # simulate a previously-generated clip + + doc.assign_character_to_range(5, 10, bob.id) + + leftover = next(c for c in doc.clips if c.character_id == alice.id) + assert leftover.id != original.id + assert leftover.segments == [] + + +def test_new_and_split_clips_are_reported_dirty(): + doc, alice, _ = _document_with_characters() + clip = doc.assign_character_to_range(0, 5, alice.id) + assert is_clip_dirty(clip, doc.clip_text(clip), doc.effective_config_for_clip(clip)) is True diff --git a/tests/daw/test_auto_split.py b/tests/daw/test_auto_split.py new file mode 100644 index 0000000..cbcce5f --- /dev/null +++ b/tests/daw/test_auto_split.py @@ -0,0 +1,203 @@ +"""Tests for kokoro_gui/daw/auto_split.py's `plan_auto_split_clips` and +kokoro_gui/daw/models.py's `Document.get_character_by_name` (item 7, +"Auto-split on generation + combined-vs-separate clip generation", of the +DAW-for-text remaining-work roadmap). Mirrors tests/daw/test_assign_character.py's +conventions - plain Python, no Qt.""" +from kokoro_gui.daw.auto_split import plan_auto_split_clips +from kokoro_gui.daw.models import Character, Document +from kokoro_gui.engine.text_extraction import find_character_fx_spans + +# --------------------------------------------------------------------------- +# Document.get_character_by_name +# --------------------------------------------------------------------------- + + +def test_get_character_by_name_exact_match(): + alice = Character.from_preset_dict("Alice", {}) + doc = Document.from_plain_text("", characters=[alice]) + assert doc.get_character_by_name("Alice") is alice + + +def test_get_character_by_name_case_insensitive(): + alice = Character.from_preset_dict("Alice", {}) + doc = Document.from_plain_text("", characters=[alice]) + assert doc.get_character_by_name("alice") is alice + assert doc.get_character_by_name("ALICE") is alice + + +def test_get_character_by_name_whitespace_tolerant(): + alice = Character.from_preset_dict("Alice", {}) + doc = Document.from_plain_text("", characters=[alice]) + assert doc.get_character_by_name(" Alice ") is alice + + +def test_get_character_by_name_no_match_returns_none(): + alice = Character.from_preset_dict("Alice", {}) + doc = Document.from_plain_text("", characters=[alice]) + assert doc.get_character_by_name("Carol") is None + assert doc.get_character_by_name(None) is None + + +# --------------------------------------------------------------------------- +# plan_auto_split_clips - combined mode (split_by_paragraph=False) +# --------------------------------------------------------------------------- + + +def test_combined_mode_one_triple_per_tagged_span(): + alice = Character.from_preset_dict("Alice", {}) + bob = Character.from_preset_dict("Bob", {}) + text = "[Alice]: Hello there.\n\n[Bob]: Hi Alice, how are you?" + doc = Document.from_plain_text(text, characters=[alice, bob]) + + triples, unmatched = plan_auto_split_clips(doc, split_by_paragraph=False) + + spans = find_character_fx_spans(text) + assert len(spans) == 2 + assert unmatched == [] + assert triples == [ + (spans[0].start, spans[0].end, alice.id), + (spans[1].start, spans[1].end, bob.id), + ] + + +# --------------------------------------------------------------------------- +# plan_auto_split_clips - auto-split mode (split_by_paragraph=True) +# --------------------------------------------------------------------------- + + +def test_auto_split_mode_splits_a_paragraph_break_into_multiple_triples(): + alice = Character.from_preset_dict("Alice", {}) + bob = Character.from_preset_dict("Bob", {}) + text = ( + "[Alice]: First paragraph.\n\n" + "Second paragraph for Alice.\n\n" + "[Bob]: Single paragraph for Bob." + ) + doc = Document.from_plain_text(text, characters=[alice, bob]) + + triples, unmatched = plan_auto_split_clips(doc, split_by_paragraph=True) + + assert unmatched == [] + alice_triples = [t for t in triples if t[2] == alice.id] + bob_triples = [t for t in triples if t[2] == bob.id] + assert len(alice_triples) == 2 + assert len(bob_triples) == 1 + + # Every triple's offsets round-trip back to non-empty, correctly-placed text. + for start, end, _character_id in triples: + assert text[start:end].strip() + assert triples == sorted(triples, key=lambda t: t[0]) + + +def test_auto_split_mode_skips_empty_paragraphs_within_a_span(): + alice = Character.from_preset_dict("Alice", {}) + bob = Character.from_preset_dict("Bob", {}) + # Four blank-line-separated newlines between the two sentences produce an + # empty middle "paragraph" per str.split('\n\n') - it must be skipped, + # not emitted as a zero-width or garbage triple. + text = "[Alice]: First paragraph.\n\n\n\nSecond paragraph." + doc = Document.from_plain_text(text, characters=[alice, bob]) + + triples, unmatched = plan_auto_split_clips(doc, split_by_paragraph=True) + + assert unmatched == [] + assert len(triples) == 2 + for start, end, character_id in triples: + assert character_id == alice.id + assert text[start:end].strip() + assert start < end + + +# --------------------------------------------------------------------------- +# unmatched tag names +# --------------------------------------------------------------------------- + + +def test_unmatched_tag_name_contributes_no_triples_and_is_reported(): + alice = Character.from_preset_dict("Alice", {}) + text = "[Carol]: I have no matching character." + doc = Document.from_plain_text(text, characters=[alice]) + + triples, unmatched = plan_auto_split_clips(doc, split_by_paragraph=False) + + assert triples == [] + assert unmatched == ["Carol"] + + +def test_unmatched_span_does_not_block_matched_spans(): + alice = Character.from_preset_dict("Alice", {}) + text = "[Carol]: Unknown speaker.\n\n[Alice]: Known speaker." + doc = Document.from_plain_text(text, characters=[alice]) + + triples, unmatched = plan_auto_split_clips(doc, split_by_paragraph=False) + + assert unmatched == ["Carol"] + assert len(triples) == 1 + assert triples[0][2] == alice.id + + +# --------------------------------------------------------------------------- +# untagged narration (Decision 2: only default to a single character) +# --------------------------------------------------------------------------- + + +def test_untagged_text_assigned_to_the_sole_character(): + alice = Character.from_preset_dict("Alice", {}) + text = "Untagged narration with no tags at all." + doc = Document.from_plain_text(text, characters=[alice]) + + triples, unmatched = plan_auto_split_clips(doc, split_by_paragraph=False) + + assert unmatched == [] + assert triples == [(0, len(text), alice.id)] + + +def test_untagged_text_produces_no_triples_with_zero_characters(): + text = "Untagged narration with no tags at all." + doc = Document.from_plain_text(text, characters=[]) + + triples, unmatched = plan_auto_split_clips(doc, split_by_paragraph=False) + + assert triples == [] + assert unmatched == [] + + +def test_untagged_text_produces_no_triples_with_two_or_more_characters(): + alice = Character.from_preset_dict("Alice", {}) + bob = Character.from_preset_dict("Bob", {}) + text = "Untagged narration with no tags at all." + doc = Document.from_plain_text(text, characters=[alice, bob]) + + triples, unmatched = plan_auto_split_clips(doc, split_by_paragraph=False) + + assert triples == [] + assert unmatched == [] + + +def test_untagged_gap_before_a_tagged_span_with_sole_character(): + alice = Character.from_preset_dict("Alice", {}) + text = "Untagged intro.\n\n[Alice]: Tagged block." + doc = Document.from_plain_text(text, characters=[alice]) + + triples, unmatched = plan_auto_split_clips(doc, split_by_paragraph=False) + + assert unmatched == [] + spans = find_character_fx_spans(text) + assert len(spans) == 1 + gap_text = text[: spans[0].start] + assert triples == [ + (0, len(gap_text), alice.id), + (spans[0].start, spans[0].end, alice.id), + ] + + +def test_whitespace_only_gap_is_skipped(): + alice = Character.from_preset_dict("Alice", {}) + text = " \n\n[Alice]: Tagged block." + doc = Document.from_plain_text(text, characters=[alice]) + + triples, unmatched = plan_auto_split_clips(doc, split_by_paragraph=False) + + assert unmatched == [] + spans = find_character_fx_spans(text) + assert triples == [(spans[0].start, spans[0].end, alice.id)] diff --git a/tests/daw/test_dirty_tracking.py b/tests/daw/test_dirty_tracking.py new file mode 100644 index 0000000..2946908 --- /dev/null +++ b/tests/daw/test_dirty_tracking.py @@ -0,0 +1,243 @@ +"""Tests for kokoro_gui/daw/dirty.py - verifies it stays a thin, faithful +read of kokoro_gui/engine/caching.py's own cache-hash/segment-prediction +logic rather than a second, divergent implementation (same spirit as +tests/test_meta_caching_policy.py's policing of tests/test_caching.py).""" +from kokoro_gui.daw.dirty import ( + compute_expected_cache_hash, + is_clip_dirty, + predict_segment_texts, +) +from kokoro_gui.daw.models import Clip, Segment +from kokoro_gui.engine.caching import compute_cache_key + + +def _config(**overrides): + base = {"voice": "af_bella", "speed": 1.0, "lang_code": "a", "engine_id": "kokoro"} + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# predict_segment_texts +# --------------------------------------------------------------------------- + +def test_predict_segment_texts_splits_on_default_pattern_and_strips_blank_lines(): + text = "First line.\n\nSecond line.\n \nThird." + assert predict_segment_texts(text, _config()) == ["First line.", "Second line.", "Third."] + + +def test_predict_segment_texts_honors_custom_split_pattern(): + text = "a|b|c" + assert predict_segment_texts(text, _config(split_pattern=r"\|")) == ["a", "b", "c"] + + +def test_predict_segment_texts_empty_text_is_empty_list(): + assert predict_segment_texts(" \n\n ", _config()) == [] + + +# --------------------------------------------------------------------------- +# compute_expected_cache_hash +# --------------------------------------------------------------------------- + +def test_compute_expected_cache_hash_matches_compute_cache_key_directly(): + config = _config() + expected = compute_cache_key("hello", "af_bella", 1.0, "a", "kokoro") + assert compute_expected_cache_hash("hello", config) == expected + + +def test_compute_expected_cache_hash_applies_pitch_speed_compensation(): + config = _config(pitch=12.0) # +12 semitones halves the effective speed + expected = compute_cache_key("hello", "af_bella", 0.5, "a", "kokoro") + assert compute_expected_cache_hash("hello", config) == expected + + +def test_compute_expected_cache_hash_changes_with_voice(): + config_a = _config(voice="af_bella") + config_b = _config(voice="af_sarah") + assert compute_expected_cache_hash("hello", config_a) != compute_expected_cache_hash("hello", config_b) + + +def test_compute_expected_cache_hash_ignores_out_dir_and_format(): + # Mirrors compute_cache_key's own contract: only text/voice/speed/lang + # affect the hash - out_dir/format/etc. apply after cache read/generation. + config_a = _config(out_dir="/a", format="wav") + config_b = _config(out_dir="/b", format="flac") + assert compute_expected_cache_hash("hello", config_a) == compute_expected_cache_hash("hello", config_b) + + +# --------------------------------------------------------------------------- +# is_clip_dirty +# --------------------------------------------------------------------------- + +def _generated_clip(text, config): + """Builds a Clip whose segments look like they were generated from + `text`/`config` right now - i.e. a clean, non-dirty clip.""" + cache_hash = compute_expected_cache_hash(text, config) + segments = [ + Segment(order_index=i, text=seg_text, cache_key=cache_hash) + for i, seg_text in enumerate(predict_segment_texts(text, config)) + ] + return Clip(segments=segments) + + +def test_never_generated_clip_is_dirty(): + clip = Clip(segments=[]) + assert is_clip_dirty(clip, "hello", _config()) is True + + +def test_freshly_generated_clip_is_not_dirty(): + config = _config() + clip = _generated_clip("hello world", config) + assert is_clip_dirty(clip, "hello world", config) is False + + +def test_editing_text_marks_clip_dirty(): + config = _config() + clip = _generated_clip("hello world", config) + assert is_clip_dirty(clip, "hello galaxy", config) is True + + +def test_changing_voice_marks_clip_dirty(): + config = _config() + clip = _generated_clip("hello world", config) + assert is_clip_dirty(clip, "hello world", _config(voice="af_sarah")) is True + + +def test_changing_segment_count_marks_clip_dirty(): + config = _config() + clip = _generated_clip("only one line", config) + assert is_clip_dirty(clip, "line one\n\nline two", config) is True + + +# --------------------------------------------------------------------------- +# segment_key through a key function (Claude/old/PLAN_tbaw_bundle.md section 2.3) +# --------------------------------------------------------------------------- + +import os + +import pytest + +from kokoro_gui.engine.caching import segment_key + + +class _FakeBackend: + """The duck type `segment_key` needs: a voice dir to resolve names in, + a version string, and optional extra inputs.""" + + id = "kokoro" + + def __init__(self, voices_dir, version="1.0", extra=None): + self.voices_dir = voices_dir + self.version = version + self.extra = extra or {} + + def engine_version(self): + return self.version + + def cache_key_extra(self, config): + return dict(self.extra) + + def resolve_voice_file(self, name, project_dir=None): + path = os.path.join(self.voices_dir, f"{os.path.basename(name)}.pt") + return os.path.abspath(path) if os.path.isfile(path) else None + + +def _key_fn_for(backend, config): + return lambda text, clip, engine_version=None: segment_key(text, config, backend, engine_version) + + +def _clip_with_file(tmp_path, text, key, version=None, name="seg.wav"): + path = tmp_path / name + path.write_bytes(b"RIFF") + return Clip(segments=[Segment(order_index=0, text=text, cache_key=key, audio_path=str(path), + engine_version=version)]) + + +def test_segment_key_for_a_custom_voice_does_not_change_when_the_voice_dir_moves(tmp_path): + dir_a = tmp_path / "a" + dir_b = tmp_path / "b" + dir_a.mkdir() + dir_b.mkdir() + (dir_a / "Mix.pt").write_bytes(b"tensor") + (dir_b / "Mix.pt").write_bytes(b"tensor") + config = _config(voice="Mix") + + key_a = segment_key("hello", config, _FakeBackend(str(dir_a))) + key_b = segment_key("hello", config, _FakeBackend(str(dir_b))) + key_path = segment_key("hello", _config(voice=str(dir_b / "Mix.pt")), _FakeBackend(str(dir_a))) + + assert key_a == key_b == key_path + (dir_b / "Mix.pt").write_bytes(b"different tensor") + assert segment_key("hello", config, _FakeBackend(str(dir_b))) != key_a + + +def test_segment_key_requires_lang_code(tmp_path): + with pytest.raises(KeyError): + segment_key("hello", {"voice": "af_bella", "speed": 1.0}, _FakeBackend(str(tmp_path))) + + +def test_segment_key_extra_inputs_dirty_the_clip(tmp_path): + """An Audio8 transcript edit changes `cache_key_extra`, which the key + function folds in - the clip is dirty even though config is unchanged.""" + config = _config(voice="af_bella") + before = _FakeBackend(str(tmp_path), extra={"ref_transcript": "Hi there."}) + after = _FakeBackend(str(tmp_path), extra={"ref_transcript": "Hi there!"}) + key_before = segment_key("hello", config, before) + clip = _clip_with_file(tmp_path, "hello", key_before) + + assert is_clip_dirty(clip, "hello", config, key_fn=_key_fn_for(before, config)) is False + assert is_clip_dirty(clip, "hello", config, key_fn=_key_fn_for(after, config)) is True + + +def test_take_enters_the_key_only_when_non_zero(tmp_path): + backend = _FakeBackend(str(tmp_path)) + base = segment_key("hello", _config(), backend) + assert segment_key("hello", _config(take=0), backend) == base + assert segment_key("hello", _config(take=1), backend) != base + + +def test_stored_engine_version_wins_while_the_file_is_present(tmp_path): + """TB9: a bundle generated with another model version opens clean.""" + config = _config() + old = _FakeBackend(str(tmp_path), version="0.9.4") + installed = _FakeBackend(str(tmp_path), version="0.7.11") + key_old = segment_key("hello", config, old) + key_fn = _key_fn_for(installed, config) + + clip = _clip_with_file(tmp_path, "hello", key_old, version="0.9.4") + assert is_clip_dirty(clip, "hello", config, key_fn=key_fn) is False + + os.remove(clip.segments[0].audio_path) + assert is_clip_dirty(clip, "hello", config, key_fn=key_fn) is True + + +def test_segment_whose_file_was_deleted_is_dirty(tmp_path): + """TB11: close-time GC or an undo across it can't leave a clip silent.""" + config = _config() + backend = _FakeBackend(str(tmp_path)) + key_fn = _key_fn_for(backend, config) + clip = _clip_with_file(tmp_path, "hello", segment_key("hello", config, backend)) + assert is_clip_dirty(clip, "hello", config, key_fn=key_fn) is False + os.remove(clip.segments[0].audio_path) + assert is_clip_dirty(clip, "hello", config, key_fn=key_fn) is True + + +def test_segment_without_audio_path_is_not_treated_as_missing(): + """A hand-built segment (no file at all) still compares by key only.""" + config = _config() + clip = _generated_clip("hello world", config) + assert is_clip_dirty(clip, "hello world", config) is False + + +def test_build_segments_from_results_stamps_key_take_and_version_from_the_engine(): + from kokoro_gui.daw.dirty import build_segments_from_results, take_from_results + + results = [ + {"path": "a.wav", "text": "hello", "duration": 1.0, "cache_key": "k1", "take": 2, "engine_version": "v"}, + {"path": "b.wav", "text": "world", "duration": 1.0, "cache_key": "k1", "take": 2, "engine_version": "v"}, + ] + segments = build_segments_from_results("fallback", results) + assert [s.cache_key for s in segments] == ["k1", "k1"] + assert [s.engine_version for s in segments] == ["v", "v"] + assert take_from_results(results) == 2 + assert build_segments_from_results("fallback", [{"path": "a.wav", "text": "x", "duration": 1.0}])[0].cache_key == "fallback" diff --git a/tests/daw/test_migration.py b/tests/daw/test_migration.py new file mode 100644 index 0000000..2184b6e --- /dev/null +++ b/tests/daw/test_migration.py @@ -0,0 +1,80 @@ +"""Tests for kokoro_gui/daw/migration.py's presets/settings -> Document +first-load migration. tmp_path-isolated preset directories, mirroring the +isolation convention tests/conftest.py's isolated_dirs fixture uses for +kokoro_engine's storage dirs.""" +import json + +from kokoro_gui.daw.migration import DEFAULT_CHARACTER_NAME, migrate_legacy_settings_to_document +from kokoro_gui.daw.models import DEFAULT_HIGHLIGHT_PALETTE + + +def _write_preset(presets_dir, name, data): + presets_dir.mkdir(parents=True, exist_ok=True) + (presets_dir / f"{name}.json").write_text(json.dumps(data), encoding="utf-8") + + +def test_migration_with_no_presets_seeds_default_character_from_settings(tmp_path): + settings = {"voice": "af_bella", "speed": 1.2, "out_dir": "/should/be/dropped"} + doc = migrate_legacy_settings_to_document(settings, str(tmp_path / "presets")) + + assert len(doc.characters) == 1 + character = doc.characters[0] + assert character.name == DEFAULT_CHARACTER_NAME + assert character.preset_data == {"voice": "af_bella", "speed": 1.2} + assert len(doc.tracks) == 1 + assert doc.tracks[0].character_id == character.id + assert doc.text == "" + assert doc.clips == [] + + +def test_migration_with_nonexistent_presets_dir_seeds_default(tmp_path): + doc = migrate_legacy_settings_to_document({"voice": "af_bella"}, str(tmp_path / "does_not_exist")) + assert len(doc.characters) == 1 + assert doc.characters[0].name == DEFAULT_CHARACTER_NAME + + +def test_migration_wraps_each_preset_file_as_a_character(tmp_path): + presets_dir = tmp_path / "presets" + _write_preset(presets_dir, "Alice", {"voice": "af_bella", "speed": 1.0}) + _write_preset(presets_dir, "Bob", {"voice": "am_michael", "speed": 0.9}) + + doc = migrate_legacy_settings_to_document({}, str(presets_dir)) + + names = {c.name for c in doc.characters} + assert names == {"Alice", "Bob"} + assert len(doc.tracks) == 2 + track_character_ids = {t.character_id for t in doc.tracks} + assert track_character_ids == {c.id for c in doc.characters} + + +def test_migration_assigns_distinct_highlight_colors_from_palette(tmp_path): + presets_dir = tmp_path / "presets" + for i in range(len(DEFAULT_HIGHLIGHT_PALETTE) + 1): + _write_preset(presets_dir, f"Character{i}", {"voice": "af_bella"}) + + doc = migrate_legacy_settings_to_document({}, str(presets_dir)) + colors = [c.highlight_color for c in doc.characters] + # Cycles back around once there are more characters than palette entries. + assert colors[0] == colors[len(DEFAULT_HIGHLIGHT_PALETTE)] + assert len(set(colors[: len(DEFAULT_HIGHLIGHT_PALETTE)])) == len(DEFAULT_HIGHLIGHT_PALETTE) + + +def test_migration_ignores_fx_subdirectory(tmp_path): + presets_dir = tmp_path / "presets" + _write_preset(presets_dir, "Alice", {"voice": "af_bella"}) + _write_preset(presets_dir / "fx", "reverb_heavy", {"reverb_enabled": True}) + + doc = migrate_legacy_settings_to_document({}, str(presets_dir)) + + assert [c.name for c in doc.characters] == ["Alice"] + + +def test_migration_skips_unparseable_preset_file(tmp_path): + presets_dir = tmp_path / "presets" + _write_preset(presets_dir, "Alice", {"voice": "af_bella"}) + presets_dir.mkdir(parents=True, exist_ok=True) + (presets_dir / "Broken.json").write_text("{not valid json", encoding="utf-8") + + doc = migrate_legacy_settings_to_document({}, str(presets_dir)) + + assert [c.name for c in doc.characters] == ["Alice"] diff --git a/tests/daw/test_mixdown.py b/tests/daw/test_mixdown.py new file mode 100644 index 0000000..e8d92a5 --- /dev/null +++ b/tests/daw/test_mixdown.py @@ -0,0 +1,136 @@ +"""Tests for kokoro_gui/daw/mixdown.py - offline export of a clip document +(section 6 of Claude/PLAN_ui_shell_redesign.md). Writes real wav files +into tmp_path; no engine, no Qt.""" +import numpy as np +import soundfile as sf + +from kokoro_gui.daw.arrangement import compute_arrangement +from kokoro_gui.daw.mixdown import mixdown, write_srt +from kokoro_gui.daw.models import Character, Clip, Document, Run, Segment, Track + + +def _doc(text, tagged, **kwargs): + runs, cursor = [], 0 + for start, end, clip in sorted(tagged, key=lambda t: t[0]): + if start > cursor: + runs.append(Run(text=text[cursor:start])) + runs.append(Run(text=text[start:end], clip_id=clip.id, kind=clip.source)) + cursor = end + if cursor < len(text): + runs.append(Run(text=text[cursor:])) + return Document(runs=runs, clips=[c for _s, _e, c in tagged], **kwargs) + + +def _wav(path, value, seconds, rate=8000): + sf.write(str(path), np.full(int(rate * seconds), value, dtype=np.float32), rate) + return str(path) + + +def _two_generated_clips(tmp_path): + alice = Character.from_preset_dict("Alice", {}) + bob = Character.from_preset_dict("Bo b/ok", {}) + track_a = Track(name="A", character_id=alice.id, order_index=0) + track_b = Track(name="B", character_id=bob.id, order_index=1) + a = Clip(character_id=alice.id, track_id=track_a.id, + segments=[Segment(order_index=0, duration=1.0, audio_path=_wav(tmp_path / "a.wav", 0.25, 1.0))]) + b = Clip(character_id=bob.id, track_id=track_b.id, + segments=[Segment(order_index=0, duration=0.5, audio_path=_wav(tmp_path / "b.wav", 0.5, 0.5))]) + doc = _doc("Hello there. General Kenobi.", [(0, 12, a), (13, 28, b)], + characters=[alice, bob], tracks=[track_a, track_b]) + return doc, a, b + + +def test_mixdown_lays_clips_end_to_end_and_writes_one_file(tmp_path): + doc, _a, _b = _two_generated_clips(tmp_path) + out = tmp_path / "out" / "mix.wav" + + result = mixdown(doc, str(out), fmt="wav", sample_rate=8000) + + data, rate = sf.read(str(out), dtype="float32") + assert rate == 8000 + assert len(data) == 12000 # 1.0s + 0.5s + assert np.allclose(data[:8000], 0.25, atol=1e-3) + assert np.allclose(data[8000:], 0.5, atol=1e-3) + assert result.audio_path == str(out) + assert result.duration_s == 1.5 + assert result.skipped_clip_ids == [] + + +def test_mixdown_sums_overlapping_pinned_clips(tmp_path): + doc, a, b = _two_generated_clips(tmp_path) + b.timeline_timestamp = 0.5 # overlaps the second half of a + + mixdown(doc, str(tmp_path / "mix.wav"), fmt="wav", sample_rate=8000) + + data, _ = sf.read(str(tmp_path / "mix.wav"), dtype="float32") + assert len(data) == 8000 + assert np.allclose(data[:4000], 0.25, atol=1e-3) + assert np.allclose(data[4000:], 0.75, atol=1e-3) + + +def test_mixdown_keeps_per_clip_files_named_by_index_and_character(tmp_path): + doc, _a, _b = _two_generated_clips(tmp_path) + + result = mixdown(doc, str(tmp_path / "story.wav"), fmt="wav", sample_rate=8000, keep_clip_files=True) + + names = [p.replace(str(tmp_path), "").strip("\\/") for p in result.clip_files] + assert names == ["story_001_Alice.wav", "story_002_Bo_b_ok.wav"] # UI14, sanitized + for path in result.clip_files: + assert sf.info(path).frames > 0 + + +def test_mixdown_writes_srt_from_arrangement(tmp_path): + doc, _a, _b = _two_generated_clips(tmp_path) + + result = mixdown(doc, str(tmp_path / "mix.wav"), fmt="wav", sample_rate=8000, include_srt=True) + + text = open(result.srt_path, encoding="utf-8").read() + assert "1\n00:00:00,000 --> 00:00:01,000\nHello there.\n" in text + assert "2\n00:00:01,000 --> 00:00:01,500\nGeneral Kenobi.\n" in text + + +def test_ungenerated_clips_are_silence_and_reported_as_skipped(tmp_path): + doc, a, b = _two_generated_clips(tmp_path) + b.segments = [] # dirty: never generated + + # An explicit arrangement pins the estimate rate (the default asks + # generation_stats.json, whose history varies per machine). + arrangement = compute_arrangement(doc, chars_per_second=15.0) + result = mixdown(doc, str(tmp_path / "mix.wav"), fmt="wav", sample_rate=8000, include_srt=True, + arrangement=arrangement) + + data, _ = sf.read(str(tmp_path / "mix.wav"), dtype="float32") + assert result.skipped_clip_ids == [b.id] + # a is 1.0s; b is estimated (15 chars / 15 cps = 1.0s) so the file is + # padded with silence to the arrangement's total. + assert len(data) == 16000 + assert np.allclose(data[8000:], 0.0) + srt = open(result.srt_path, encoding="utf-8").read() + assert "General Kenobi" not in srt + + +def test_write_srt_skips_estimated_and_empty_clips(tmp_path): + doc, a, b = _two_generated_clips(tmp_path) + arrangement = compute_arrangement(doc, chars_per_second=15.0) + b.segments = [] + arrangement = compute_arrangement(doc, chars_per_second=15.0) + + write_srt(doc, arrangement, str(tmp_path / "x.srt")) + + assert open(tmp_path / "x.srt", encoding="utf-8").read().count("-->") == 1 + + +def test_mixdown_applies_post_config_per_clip(tmp_path): + """Export reads the raw segment files through the same read-time + post-processing the transport plays (kokoro_gui/audio/post.py).""" + doc, a, _b = _two_generated_clips(tmp_path) + out = tmp_path / "out" / "mix.wav" + + def post_for(clip): + return {"volume": 2.0, "apply_fx": False} if clip.id == a.id else None + + mixdown(doc, str(out), fmt="wav", sample_rate=8000, post_config_for_clip=post_for) + + data, _rate = sf.read(str(out), dtype="float32") + assert np.allclose(data[:8000], 0.5, atol=1e-3) # a: 0.25 * 2 + assert np.allclose(data[8000:], 0.5, atol=1e-3) # b: untouched diff --git a/tests/daw/test_models.py b/tests/daw/test_models.py new file mode 100644 index 0000000..4314e5e --- /dev/null +++ b/tests/daw/test_models.py @@ -0,0 +1,236 @@ +"""Plain-Python tests for kokoro_gui/daw/models.py - no Qt, no QT_QPA_PLATFORM +needed, mirroring how tests/test_caching.py tests kokoro_gui/engine/caching.py +with zero GUI dependency.""" +import pytest + +from kokoro_gui.daw.models import Character, Clip, Document, Run, Segment, Track + + +# --------------------------------------------------------------------------- +# Character +# --------------------------------------------------------------------------- + +def test_character_from_preset_dict_filters_disallowed_keys(): + # out_dir/filename are never allowed into a preset-derived trust + # boundary (see kokoro_gui/engine/presets.py's ALLOWED_PRESET_KEYS docs). + character = Character.from_preset_dict( + "Alice", {"voice": "af_bella", "speed": 1.2, "out_dir": "/etc", "filename": "pwn"} + ) + assert character.preset_data == {"voice": "af_bella", "speed": 1.2} + assert character.name == "Alice" + assert character.id # auto-assigned + + +def test_character_to_preset_dict_round_trips(): + original = {"voice": "af_bella", "speed": 1.0} + character = Character.from_preset_dict("Alice", original) + assert character.to_preset_dict() == original + + +def test_character_ids_are_unique(): + a = Character.from_preset_dict("A", {}) + b = Character.from_preset_dict("B", {}) + assert a.id != b.id + + +# --------------------------------------------------------------------------- +# Clip +# --------------------------------------------------------------------------- + +def test_clip_rejects_invalid_source(): + with pytest.raises(ValueError): + Clip(source="recorded") + + +def test_clip_default_source_is_generated(): + assert Clip().source == "generated" + + +def test_clip_has_no_offset_fields(): + # The whole point of the run-based rework: a Clip's extent lives in + # Document.runs, not on the Clip object itself. + clip = Clip() + assert not hasattr(clip, "start_offset") + assert not hasattr(clip, "end_offset") + + +# --------------------------------------------------------------------------- +# Document construction / text (derived property) +# --------------------------------------------------------------------------- + +def test_from_plain_text_creates_one_untagged_run(): + doc = Document.from_plain_text("hello world") + assert doc.text == "hello world" + assert len(doc.runs) == 1 + assert doc.runs[0].clip_id is None + + +def test_from_plain_text_empty_string_creates_no_runs(): + doc = Document.from_plain_text("") + assert doc.text == "" + assert doc.runs == [] + + +def test_text_is_the_join_of_every_run(): + doc = Document(runs=[Run(text="hello "), Run(text="world", clip_id="c1")]) + assert doc.text == "hello world" + + +def test_set_plain_text_discards_existing_tags(): + doc = Document(runs=[Run(text="hello", clip_id="c1")]) + doc.set_plain_text("goodbye") + assert doc.text == "goodbye" + assert doc.runs == [Run(text="goodbye")] + + +# --------------------------------------------------------------------------- +# Document lookups +# --------------------------------------------------------------------------- + +def _make_document_with_one_character(): + character = Character.from_preset_dict("Alice", {"voice": "af_bella", "speed": 1.0}) + track = Track(name="Alice", character_id=character.id) + clip = Clip(character_id=character.id, track_id=track.id) + doc = Document( + runs=[Run(text="hello", clip_id=clip.id, kind=clip.source), Run(text=" world")], + clips=[clip], tracks=[track], characters=[character], + ) + return doc, character, track, clip + + +def test_get_character_get_track_get_clip(): + doc, character, track, clip = _make_document_with_one_character() + assert doc.get_character(character.id) is character + assert doc.get_track(track.id) is track + assert doc.get_clip(clip.id) is clip + assert doc.get_character(None) is None + assert doc.get_character("nonexistent") is None + + +def test_clip_covering_inclusive_start_exclusive_end(): + doc, _, _, clip = _make_document_with_one_character() + assert doc.clip_covering(0) is clip + assert doc.clip_covering(4) is clip + assert doc.clip_covering(5) is None # " world" is untagged + assert doc.clip_covering(100) is None + + +def test_clip_extent_walks_runs(): + doc, _, _, clip = _make_document_with_one_character() + assert doc.clip_extent(clip.id) == (0, 5) + assert doc.clip_extent("nonexistent") is None + + +def test_clip_text_slices_document_text(): + doc, _, _, clip = _make_document_with_one_character() + assert doc.clip_text(clip) == "hello" + + +def test_effective_config_merges_character_preset_and_overrides(): + doc, character, _, clip = _make_document_with_one_character() + clip.overrides = {"speed": 1.5} # override wins over the character's speed=1.0 + config = doc.effective_config_for_clip(clip) + assert config == {"voice": "af_bella", "speed": 1.5} + + +def test_effective_config_filters_disallowed_override_keys(): + doc, _, _, clip = _make_document_with_one_character() + clip.overrides = {"out_dir": "/etc"} + config = doc.effective_config_for_clip(clip) + assert "out_dir" not in config + + +def test_effective_config_with_no_character_uses_only_overrides(): + clip = Clip(character_id=None, overrides={"voice": "af_bella"}) + doc = Document(runs=[Run(text="hello", clip_id=clip.id)], clips=[clip]) + assert doc.effective_config_for_clip(clip) == {"voice": "af_bella"} + + +# --------------------------------------------------------------------------- +# Document.replace_text +# --------------------------------------------------------------------------- + +def test_replace_text_pure_insertion_outside_any_clip(): + doc = Document.from_plain_text("0123456789") + removed = doc.replace_text(position=2, chars_removed=0, chars_added=3, new_text="01XYZ23456789") + assert removed == [] + assert doc.text == "01XYZ23456789" + + +def test_replace_text_leaves_clip_entirely_before_edit_untouched(): + clip = Clip() + doc = Document(runs=[Run(text="ABC", clip_id=clip.id), Run(text="DEFGHIJ")], clips=[clip]) + # Edit at position 4..6 - a full untagged character (index 3, "D") sits + # between the clip's run and the edit, so this can't be mistaken for + # "typing right at the clip's boundary" (see the inherited-tag rule). + new_text = "ABCD" + "XX" + "GHIJ" + doc.replace_text(position=4, chars_removed=2, chars_added=2, new_text=new_text) + assert doc.clip_extent(clip.id) == (0, 3) + assert doc.text == new_text + + +def test_replace_text_extends_clip_edited_in_place(): + # Clip spans "world" in "hello world". + clip = Clip() + doc = Document(runs=[Run(text="hello "), Run(text="world", clip_id=clip.id)], clips=[clip]) + old_text = doc.text + position = 8 # inside the clip's run ("world" spans offsets 6..11) + inserted = "onderful " + new_text = old_text[:position] + inserted + old_text[position:] + + doc.replace_text(position=position, chars_removed=0, chars_added=len(inserted), new_text=new_text) + + assert doc.text == new_text + # The insertion sat inside the clip's run, so it extends that same clip + # rather than leaving a gap or spilling into the surrounding untagged text. + assert doc.clip_text(clip) == "world"[:2] + inserted + "world"[2:] + start, end = doc.clip_extent(clip.id) + assert new_text[start:end] == doc.clip_text(clip) + + +def test_replace_text_removes_clip_fully_consumed_by_deletion(): + clip = Clip() + doc = Document(runs=[Run(text="hello "), Run(text="world", clip_id=clip.id)], clips=[clip]) + new_text = "hello " + removed = doc.replace_text(position=6, chars_removed=5, chars_added=0, new_text=new_text) + assert removed == [clip] + assert clip not in doc.clips + assert doc.text == new_text + + +def test_replace_text_removes_clip_when_replacement_spans_it(): + clip = Clip() + doc = Document(runs=[Run(text="hello "), Run(text="world", clip_id=clip.id)], clips=[clip]) + # Replace a range that starts before and ends after the clip's run. + new_text = "hel" + "EVERYONE!" + removed = doc.replace_text(position=3, chars_removed=8, chars_added=9, new_text=new_text) + assert removed == [clip] + assert doc.text == new_text + # The inserted text was untagged (position 2, right before the edit, is + # part of the untagged "hello " run) - it does not inherit the consumed + # clip's id. + assert doc.clip_covering(5) is None + + +def test_replace_text_at_document_start_is_untagged_by_default(): + clip = Clip() + doc = Document(runs=[Run(text="hello", clip_id=clip.id)], clips=[clip]) + doc.replace_text(position=0, chars_removed=0, chars_added=3, new_text="Hi!hello") + assert doc.clip_covering(0) is None + assert doc.clip_extent(clip.id) == (3, 8) + + +def test_dirty_clips_delegates_to_dirty_module(monkeypatch): + doc, _, _, clip = _make_document_with_one_character() + other = Clip() + doc.clips.append(other) # untagged - no run points at it + + calls = [] + + def fake_is_dirty(c, text, config, key_fn=None): + calls.append(c) + return c is clip + + monkeypatch.setattr("kokoro_gui.daw.dirty.is_clip_dirty", fake_is_dirty) + assert doc.dirty_clips() == [clip] + assert calls == [clip, other] diff --git a/tests/daw/test_serialization.py b/tests/daw/test_serialization.py new file mode 100644 index 0000000..0163977 --- /dev/null +++ b/tests/daw/test_serialization.py @@ -0,0 +1,228 @@ +"""Tests for kokoro_gui/daw/serialization.py's document.json round trip.""" +from kokoro_gui.daw.models import Character, Clip, Document, Run, Segment, Track +from kokoro_gui.daw.serialization import ( + document_from_dict, + document_to_dict, + load_document, + save_document, +) + + +def _sample_document(): + character = Character.from_preset_dict("Alice", {"voice": "af_bella", "speed": 1.1}) + track = Track(name="Alice", character_id=character.id) + segment = Segment(order_index=0, text="hello", cache_key="deadbeef", duration=1.5) + clip = Clip( + character_id=character.id, track_id=track.id, + overrides={"speed": 1.5}, segments=[segment], + ) + doc = Document( + runs=[Run(text="hello", clip_id=clip.id, kind=clip.source), Run(text=" world")], + clips=[clip], tracks=[track], characters=[character], settings={"x": 1}, + ) + return doc + + +def test_document_to_dict_is_json_plain_shapes(): + doc = _sample_document() + data = document_to_dict(doc) + assert data["settings"] == {"x": 1} + assert data["runs"][0]["text"] == "hello" + assert data["runs"][0]["clip_id"] == doc.clips[0].id + assert data["clips"][0]["segments"][0]["cache_key"] == "deadbeef" + assert data["characters"][0]["preset_data"] == {"voice": "af_bella", "speed": 1.1} + + +def test_document_from_dict_round_trips_to_dict(): + doc = _sample_document() + restored = document_from_dict(document_to_dict(doc)) + + assert restored.text == doc.text + assert restored.settings == doc.settings + assert len(restored.clips) == 1 + assert restored.clips[0].id == doc.clips[0].id + assert restored.clips[0].overrides == {"speed": 1.5} + assert len(restored.clips[0].segments) == 1 + assert restored.clips[0].segments[0].cache_key == "deadbeef" + assert restored.tracks[0].id == doc.tracks[0].id + assert restored.characters[0].preset_data == {"voice": "af_bella", "speed": 1.1} + assert restored.clip_extent(restored.clips[0].id) == doc.clip_extent(doc.clips[0].id) + + +def test_document_from_dict_tolerates_missing_keys(): + restored = document_from_dict({}) + assert restored.text == "" + assert restored.runs == [] + assert restored.clips == [] + assert restored.tracks == [] + assert restored.characters == [] + assert restored.settings == {} + + +def test_save_and_load_document_round_trip(tmp_path): + doc = _sample_document() + path = tmp_path / "sub" / "document.json" + save_document(doc, str(path)) + assert path.exists() + + loaded = load_document(str(path)) + assert loaded.text == doc.text + assert loaded.clips[0].id == doc.clips[0].id + + +def test_load_document_missing_file_returns_none(tmp_path): + assert load_document(str(tmp_path / "nope.json")) is None + + +def test_load_document_corrupt_json_returns_none(tmp_path): + path = tmp_path / "document.json" + path.write_text("{not valid json", encoding="utf-8") + assert load_document(str(path)) is None + + +def test_generated_clip_round_trips_through_save_and_load(tmp_path, engine, fake_pipeline, make_config): + """Segment round-tripping was previously only exercised with hand-built + data (see _sample_document above) - this drives it through the actual + per-clip Generate path (kokoro_gui/engine/conversion.py's + generate_clip_audio) so real generation-shaped Segments are covered too.""" + import asyncio + + from kokoro_gui.daw.dirty import compute_expected_cache_hash + + character = Character.from_preset_dict("Alice", {"voice": "af_heart", "speed": 1.0}) + track = Track(name="Alice", character_id=character.id) + clip = Clip(character_id=character.id, track_id=track.id) + doc = Document( + runs=[Run(text="hello world", clip_id=clip.id, kind=clip.source)], + clips=[clip], tracks=[track], characters=[character], + ) + + config = make_config(voice="af_heart", speed=1.0) + text = doc.clip_text(clip) + results = asyncio.run(engine.generate_clip_audio((0, text, config))) + expected_hash = compute_expected_cache_hash(text, config) + clip.segments = [ + Segment(order_index=i, text=r["text"], cache_key=expected_hash, audio_path=r["path"], duration=r["duration"]) + for i, r in enumerate(results) + ] + + path = tmp_path / "document.json" + save_document(doc, str(path)) + loaded = load_document(str(path)) + + assert len(loaded.clips[0].segments) == len(clip.segments) + for original, restored in zip(clip.segments, loaded.clips[0].segments): + assert restored.order_index == original.order_index + assert restored.cache_key == original.cache_key + assert restored.audio_path == original.audio_path + assert restored.duration == original.duration + + +# --------------------------------------------------------------------------- +# Legacy (pre-run-list) document.json migration +# --------------------------------------------------------------------------- + +def test_document_from_dict_migrates_legacy_offset_shape(): + """A document.json written before the tagged-run rework has no "runs" + key at all - just a flat "text" string plus offset-ranged clips. Loading + one should synthesize an equivalent run list on the fly, per + Claude/PLAN_text_editor_redesign.md's "Migration path" section.""" + character = Character.from_preset_dict("Alice", {"voice": "af_bella"}) + legacy_data = { + "text": "hello world", + "clips": [ + { + "start_offset": 6, "end_offset": 11, + "character_id": character.id, "track_id": None, + "overrides": {}, "fx_override": None, "timeline_timestamp": None, + "segments": [], "source": "generated", "original_audio_path": None, + "id": "legacy-clip-id", + }, + ], + "tracks": [], + "characters": [{"name": "Alice", "preset_data": {"voice": "af_bella"}, + "highlight_color": "#f4b400", "backend_id": "kokoro", "id": character.id}], + "settings": {}, + } + + doc = document_from_dict(legacy_data) + + assert doc.text == "hello world" + clip = doc.get_clip("legacy-clip-id") + assert clip is not None + assert doc.clip_extent(clip.id) == (6, 11) + assert doc.clip_text(clip) == "world" + assert doc.clip_covering(0) is None # "hello " stays untagged + + +def test_document_from_dict_migrates_legacy_shape_with_gap_at_start(): + legacy_data = { + "text": "0123456789", + "clips": [ + { + "start_offset": 5, "end_offset": 10, + "character_id": None, "track_id": None, + "overrides": {}, "fx_override": None, "timeline_timestamp": None, + "segments": [], "source": "generated", "original_audio_path": None, + "id": "clip-a", + }, + ], + "tracks": [], "characters": [], "settings": {}, + } + + doc = document_from_dict(legacy_data) + + assert doc.text == "0123456789" + assert doc.clip_covering(0) is None + assert doc.clip_covering(5).id == "clip-a" + assert doc.clip_extent("clip-a") == (5, 10) + + +# --------------------------------------------------------------------------- +# Unknown fields round-trip (Claude/old/PLAN_tbaw_bundle.md section 2.2) +# --------------------------------------------------------------------------- + +def test_unknown_keys_on_every_object_survive_a_round_trip(): + data = { + "runs": [{"text": "hello", "clip_id": "c1", "kind": "generated", "future_run_key": 1}], + "clips": [{ + "id": "c1", "character_id": "ch1", "future_clip_key": {"nested": True}, + "segments": [{"order_index": 0, "text": "hello", "cache_key": "k", "raw": True, + "word_timings": [[0, 0.5]]}], + }], + "tracks": [{"name": "T", "id": "t1", "future_track_key": "x"}], + "characters": [{ + "name": "Alice", "id": "ch1", "library_id": "lib-1", + "preset_data": {"voice": "af_bella", "unknown_preset_key": 7}, + }], + "settings": {}, + } + doc = document_from_dict(data) + + # The whitelist still guards what reaches a config dict. + assert doc.characters[0].preset_data == {"voice": "af_bella"} + assert doc.characters[0].extra == {"library_id": "lib-1", "preset_data": {"unknown_preset_key": 7}} + assert doc.clips[0].extra == {"future_clip_key": {"nested": True}} + assert doc.clips[0].segments[0].extra == {"word_timings": [[0, 0.5]]} + assert doc.runs[0].extra == {"future_run_key": 1} + assert doc.tracks[0].extra == {"future_track_key": "x"} + + out = document_to_dict(doc) + assert out["runs"][0]["future_run_key"] == 1 + assert out["clips"][0]["future_clip_key"] == {"nested": True} + assert out["clips"][0]["segments"][0]["word_timings"] == [[0, 0.5]] + assert out["tracks"][0]["future_track_key"] == "x" + assert out["characters"][0]["library_id"] == "lib-1" + assert out["characters"][0]["preset_data"] == {"voice": "af_bella", "unknown_preset_key": 7} + assert "extra" not in out["clips"][0] and "extra" not in out["characters"][0] + + # And it reads back the same at the dict level. + assert document_to_dict(document_from_dict(out)) == out + + +def test_segment_engine_version_round_trips(): + segment = Segment(order_index=0, text="x", cache_key="k", engine_version="0.9.4") + clip = Clip(segments=[segment]) + doc = Document(runs=[Run(text="x", clip_id=clip.id)], clips=[clip]) + restored = document_from_dict(document_to_dict(doc)) + assert restored.clips[0].segments[0].engine_version == "0.9.4" diff --git a/tests/daw/test_undo.py b/tests/daw/test_undo.py new file mode 100644 index 0000000..7bb77fb --- /dev/null +++ b/tests/daw/test_undo.py @@ -0,0 +1,383 @@ +"""Tests for kokoro_gui/daw/undo.py - the plain-Python Command/UndoStack +pair behind item 4 ("Undo/redo") of the DAW-for-text redesign's +remaining-work roadmap. Mirrors tests/daw/test_assign_character.py's +fixtures/conventions - no Qt, no QT_QPA_PLATFORM needed. + +Per Claude/PLAN_text_editor_redesign.md, `TextEditCommand` here models the +custom-stack's one remaining text-mutation use (the sub-range TTS replace +button - a programmatic, non-typing text replacement), not interactive +typing, which now rides the real GUI's native QTextDocument undo instead +(see kokoro_gui/qt/transcript_editor.py).""" +import json + +from kokoro_gui.daw.models import Character, Clip, Document, Run, Track +from kokoro_gui.daw.serialization import document_from_dict, document_to_dict, load_document, save_document +from kokoro_gui.daw.undo import AssignCharacterCommand, SetClipFxCommand, TextEditCommand, UndoStack + + +def _document_with_characters(text="0123456789ABCDEFGHIJ"): + alice = Character.from_preset_dict("Alice", {"voice": "af_bella"}) + bob = Character.from_preset_dict("Bob", {"voice": "am_michael"}) + tracks = [Track(name="Alice", character_id=alice.id), Track(name="Bob", character_id=bob.id)] + doc = Document.from_plain_text(text, characters=[alice, bob], tracks=tracks) + return doc, alice, bob + + +# --------------------------------------------------------------------------- +# AssignCharacterCommand round trip +# --------------------------------------------------------------------------- + +def test_assign_character_command_push_creates_clip(): + doc, alice, _ = _document_with_characters() + stack = UndoStack(doc) + + stack.push(AssignCharacterCommand(2, 6, alice.id)) + + assert len(doc.clips) == 1 + clip = doc.clips[0] + assert doc.clip_extent(clip.id) == (2, 6) + assert clip.character_id == alice.id + + +def test_assign_character_command_undo_removes_clip(): + doc, alice, _ = _document_with_characters() + stack = UndoStack(doc) + stack.push(AssignCharacterCommand(2, 6, alice.id)) + + stack.undo() + + assert doc.clips == [] + assert doc.clip_covering(3) is None + + +def test_assign_character_command_redo_restores_clip_with_same_properties(): + doc, alice, _ = _document_with_characters() + stack = UndoStack(doc) + stack.push(AssignCharacterCommand(2, 6, alice.id)) + stack.undo() + + stack.redo() + + assert len(doc.clips) == 1 + clip = doc.clips[0] + assert doc.clip_extent(clip.id) == (2, 6) + assert clip.character_id == alice.id + + +def test_assign_character_command_undo_restores_split_leftovers_verbatim(): + doc, alice, bob = _document_with_characters() + stack = UndoStack(doc) + original = doc.assign_character_to_range(0, 10, alice.id) + original.segments = ["pretend-generated"] # simulate previously-generated audio + original_id = original.id + + stack.push(AssignCharacterCommand(5, 10, bob.id)) + assert len(doc.clips) == 2 # alice leftover [0,5) + bob [5,10) + + stack.undo() + + assert len(doc.clips) == 1 + restored = doc.clips[0] + assert restored.id == original_id + assert doc.clip_extent(restored.id) == (0, 10) + assert restored.character_id == alice.id + assert restored.segments == ["pretend-generated"] # cache-hit-preserving restore + + +# --------------------------------------------------------------------------- +# TextEditCommand round trip +# --------------------------------------------------------------------------- + +_INSERTED_AFTER_HELLO = ", there" + + +def _text_edit_insert_after_hello(old_text="hello world"): + position = 5 # right after "hello" + new_text = old_text[:position] + _INSERTED_AFTER_HELLO + old_text[position:] + return TextEditCommand(position, 0, len(_INSERTED_AFTER_HELLO), new_text=new_text), new_text + + +def test_text_edit_command_push_changes_document_text(): + doc, _, _ = _document_with_characters(text="hello world") + stack = UndoStack(doc) + + command, new_text = _text_edit_insert_after_hello() + stack.push(command) + + assert doc.text == new_text + + +def test_text_edit_command_undo_restores_original_text(): + doc, _, _ = _document_with_characters(text="hello world") + stack = UndoStack(doc) + command, _new_text = _text_edit_insert_after_hello() + stack.push(command) + + stack.undo() + + assert doc.text == "hello world" + + +def test_text_edit_command_redo_reapplies_insertion(): + doc, _, _ = _document_with_characters(text="hello world") + stack = UndoStack(doc) + command, new_text = _text_edit_insert_after_hello() + stack.push(command) + stack.undo() + + stack.redo() + + assert doc.text == new_text + + +def test_text_edit_command_undo_restores_exact_clip_tagging_across_a_split(): + """A clip covers [5, 15). The edit replaces [10, 20) - it starts inside + the clip (position 10 sits strictly within it) and ends past it. The + replacement text inherits the clip's tag (ordinary "typing inside a + run extends it" behavior), so the clip survives, now covering its + original [5, 10) portion plus the 3-character replacement. Undo must + restore the clip's exact original extent, not whatever a naive + reverse-replay of the edit would reconstruct.""" + text = "0123456789ABCDEFGHIJKLMNOPQRST" # len 30 + doc, alice, _ = _document_with_characters(text=text) + stack = UndoStack(doc) + stack.push(AssignCharacterCommand(5, 15, alice.id)) + clip = doc.clips[0] + + new_text = text[:10] + "XYZ" + text[20:] # replace [10,20) (10 chars) with "XYZ" (3 chars) + command = TextEditCommand(10, 10, 3, new_text=new_text) + stack.push(command) + + # Forward edit: clip survives, now [5, 10) plus the inherited "XYZ". + assert doc.clip_extent(clip.id) == (5, 13) + assert doc.clip_text(clip) == "56789XYZ" + + stack.undo() + + assert doc.text == text + restored = doc.get_clip(clip.id) + assert restored is not None + assert doc.clip_extent(restored.id) == (5, 15) # exact pre-edit extent, not a naive replay's guess + + +def test_text_edit_command_undo_restores_fully_consumed_clip(): + doc, alice, _ = _document_with_characters(text="hello world") + stack = UndoStack(doc) + clip = doc.assign_character_to_range(6, 11, alice.id) + clip.segments = ["pretend-generated"] + + command = TextEditCommand(6, 5, 0, new_text="hello ") + stack.push(command) + assert doc.clips == [] + + stack.undo() + + assert len(doc.clips) == 1 + restored = doc.clips[0] + assert restored.id == clip.id + assert doc.clip_extent(restored.id) == (6, 11) + assert restored.segments == ["pretend-generated"] + + +# --------------------------------------------------------------------------- +# SetClipFxCommand round trip (item 5, "Per-clip FX button") +# --------------------------------------------------------------------------- + +def test_set_clip_fx_command_push_sets_fx_override(): + doc = Document(runs=[Run(text="hello")]) + clip = Clip() + doc.clips.append(clip) + stack = UndoStack(doc) + + stack.push(SetClipFxCommand(clip.id, {"reverb_enabled": True, "comp_threshold": -10})) + + assert clip.fx_override == {"reverb_enabled": True, "comp_threshold": -10} + + +def test_set_clip_fx_command_undo_restores_none_when_previously_unset(): + doc = Document(runs=[Run(text="hello")]) + clip = Clip() + doc.clips.append(clip) + stack = UndoStack(doc) + stack.push(SetClipFxCommand(clip.id, {"reverb_enabled": True})) + + stack.undo() + + assert clip.fx_override is None + + +def test_set_clip_fx_command_redo_reapplies_fx_override(): + doc = Document(runs=[Run(text="hello")]) + clip = Clip() + doc.clips.append(clip) + stack = UndoStack(doc) + stack.push(SetClipFxCommand(clip.id, {"reverb_enabled": True})) + stack.undo() + + stack.redo() + + assert clip.fx_override == {"reverb_enabled": True} + + +def test_set_clip_fx_command_clears_a_previously_set_override(): + doc = Document(runs=[Run(text="hello")]) + clip = Clip(fx_override={"reverb_enabled": True}) + doc.clips.append(clip) + stack = UndoStack(doc) + + stack.push(SetClipFxCommand(clip.id, None)) + + assert clip.fx_override is None + + +def test_set_clip_fx_command_undo_restores_previous_override_after_clear(): + doc = Document(runs=[Run(text="hello")]) + clip = Clip(fx_override={"reverb_enabled": True, "comp_ratio": 4}) + doc.clips.append(clip) + stack = UndoStack(doc) + stack.push(SetClipFxCommand(clip.id, None)) + assert clip.fx_override is None + + stack.undo() + + assert clip.fx_override == {"reverb_enabled": True, "comp_ratio": 4} + + +def test_set_clip_fx_command_does_not_alias_caller_dict(): + doc = Document(runs=[Run(text="hello")]) + clip = Clip() + doc.clips.append(clip) + stack = UndoStack(doc) + fx_values = {"reverb_enabled": True} + + stack.push(SetClipFxCommand(clip.id, fx_values)) + fx_values["reverb_enabled"] = False # mutate the caller's own dict afterward + + assert clip.fx_override == {"reverb_enabled": True} # unaffected + + +# --------------------------------------------------------------------------- +# Mixed sequence +# --------------------------------------------------------------------------- + +def test_mixed_sequence_type_then_assign_undo_twice_redo_once(): + doc, alice, _ = _document_with_characters(text="hello world") + stack = UndoStack(doc) + + # 1. Programmatically replace text: insert ", there" after "hello" (position 5). + text_cmd = TextEditCommand(5, 0, 7, new_text="hello, there world") + stack.push(text_cmd) + assert doc.text == "hello, there world" + + # 2. Assign a character to a range of the new text. + assign_cmd = AssignCharacterCommand(0, 5, alice.id) + stack.push(assign_cmd) + assert len(doc.clips) == 1 + + # Undo twice: first reverts the assignment, then the text edit. + stack.undo() + assert doc.clips == [] + assert doc.text == "hello, there world" + + stack.undo() + assert doc.text == "hello world" + + # Redo once: only the text edit comes back, not the assignment. + stack.redo() + assert doc.text == "hello, there world" + assert doc.clips == [] + + +# --------------------------------------------------------------------------- +# can_undo/can_redo and push()-clears-redo +# --------------------------------------------------------------------------- + +def test_can_undo_can_redo_track_state_through_push_undo_redo(): + doc, alice, _ = _document_with_characters() + stack = UndoStack(doc) + assert stack.can_undo() is False + assert stack.can_redo() is False + + stack.push(AssignCharacterCommand(0, 5, alice.id)) + assert stack.can_undo() is True + assert stack.can_redo() is False + + stack.undo() + assert stack.can_undo() is False + assert stack.can_redo() is True + + stack.redo() + assert stack.can_undo() is True + assert stack.can_redo() is False + + +def test_push_clears_redo_stack(): + doc, alice, bob = _document_with_characters() + stack = UndoStack(doc) + stack.push(AssignCharacterCommand(0, 5, alice.id)) + stack.undo() + assert stack.can_redo() is True + + stack.push(AssignCharacterCommand(10, 15, bob.id)) + + assert stack.can_redo() is False + stack.redo() # no-op - nothing to redo + assert len(doc.clips) == 1 + assert doc.clips[0].character_id == bob.id + + +def test_undo_on_empty_stack_is_a_noop(): + doc, _, _ = _document_with_characters() + stack = UndoStack(doc) + stack.undo() # must not raise + assert doc.clips == [] + + +def test_redo_on_empty_stack_is_a_noop(): + doc, _, _ = _document_with_characters() + stack = UndoStack(doc) + stack.redo() # must not raise + assert doc.clips == [] + + +# --------------------------------------------------------------------------- +# undo_stack is never serialized +# --------------------------------------------------------------------------- + +def test_document_to_dict_never_includes_undo_stack(): + doc, alice, _ = _document_with_characters() + doc.undo_stack.push(AssignCharacterCommand(0, 5, alice.id)) + + data = document_to_dict(doc) + + assert "undo_stack" not in data + assert "undo_stack" not in json.dumps(data) + + +def test_document_from_dict_constructs_a_fresh_undo_stack(): + doc, alice, _ = _document_with_characters() + doc.undo_stack.push(AssignCharacterCommand(0, 5, alice.id)) + + data = document_to_dict(doc) + reloaded = document_from_dict(data) + + assert reloaded.undo_stack is not None + assert reloaded.undo_stack.can_undo() is False + assert reloaded.undo_stack.can_redo() is False + + +def test_save_and_load_document_round_trip_excludes_undo_stack(tmp_path): + doc, alice, _ = _document_with_characters() + doc.undo_stack.push(AssignCharacterCommand(0, 5, alice.id)) + + path = str(tmp_path / "document.json") + save_document(doc, path) + + with open(path, "r", encoding="utf-8") as fh: + raw = fh.read() + assert "undo_stack" not in raw + + reloaded = load_document(path) + assert reloaded is not None + assert len(reloaded.clips) == 1 + assert reloaded.undo_stack.can_undo() is False diff --git a/tests/daw/test_undo_timeline.py b/tests/daw/test_undo_timeline.py new file mode 100644 index 0000000..6e99855 --- /dev/null +++ b/tests/daw/test_undo_timeline.py @@ -0,0 +1,81 @@ +"""Tests for the seconds-axis undo commands (UI9) and the named FX +override (UI shell pass) in kokoro_gui/daw/undo.py.""" +from kokoro_gui.daw.models import Character, Clip, Document, Run, Track +from kokoro_gui.daw.undo import MoveClipBeforeCommand, SetClipFxCommand, SetClipTimestampCommand + + +def _doc(): + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="A", character_id=alice.id) + a = Clip(character_id=alice.id, track_id=track.id) + b = Clip(character_id=alice.id, track_id=track.id) + c = Clip(character_id=alice.id, track_id=track.id) + runs = [ + Run(text="AAA ", clip_id=a.id, kind="generated"), + Run(text="plain "), + Run(text="BBB ", clip_id=b.id, kind="generated"), + Run(text="CCC", clip_id=c.id, kind="generated"), + ] + doc = Document(runs=runs, clips=[a, b, c], tracks=[track], characters=[alice]) + return doc, a, b, c + + +def test_set_clip_timestamp_is_undoable(): + doc, a, _b, _c = _doc() + doc.undo_stack.push(SetClipTimestampCommand(a.id, 3.5)) + assert a.timeline_timestamp == 3.5 + doc.undo_stack.undo() + assert a.timeline_timestamp is None + doc.undo_stack.redo() + assert a.timeline_timestamp == 3.5 + doc.undo_stack.push(SetClipTimestampCommand(a.id, None)) + assert a.timeline_timestamp is None + + +def test_move_clip_before_reorders_runs_and_pins_timestamp(): + doc, a, b, c = _doc() + + doc.undo_stack.push(MoveClipBeforeCommand(c.id, a.id, timestamp=0.0)) + + assert doc.text == "CCCAAA plain BBB " + assert doc.clip_extent(c.id) == (0, 3) + assert doc.clip_extent(a.id) == (3, 7) + assert c.timeline_timestamp == 0.0 + doc.undo_stack.undo() + assert doc.text == "AAA plain BBB CCC" + assert doc.get_clip(c.id).timeline_timestamp is None + + +def test_move_clip_before_leaves_untagged_text_in_place(): + doc, a, b, c = _doc() + doc.undo_stack.push(MoveClipBeforeCommand(b.id, a.id)) + assert doc.text == "BBB AAA plain CCC" + + +def test_move_clip_before_itself_or_unknown_target_is_a_noop(): + doc, a, _b, _c = _doc() + before = doc.text + doc.undo_stack.push(MoveClipBeforeCommand(a.id, a.id)) + doc.undo_stack.push(MoveClipBeforeCommand(a.id, "nope")) + assert doc.text == before + + +def test_set_clip_fx_records_and_clears_the_preset_name(): + doc, a, _b, _c = _doc() + doc.undo_stack.push(SetClipFxCommand(a.id, {"reverb_enabled": True}, preset_name="Hall")) + assert a.fx_override == {"reverb_enabled": True} + assert a.overrides["fx_preset"] == "Hall" + + doc.undo_stack.push(SetClipFxCommand(a.id, {"reverb_enabled": False}, preset_name=None)) + assert a.overrides["fx_preset"] == "Hall" # an unnamed edit keeps the last name + + doc.undo_stack.push(SetClipFxCommand(a.id, None)) + assert a.fx_override is None + assert "fx_preset" not in a.overrides + + doc.undo_stack.undo() + assert a.overrides["fx_preset"] == "Hall" + doc.undo_stack.undo() + doc.undo_stack.undo() + assert a.fx_override is None + assert "fx_preset" not in a.overrides diff --git a/tests/gui_qt/__init__.py b/tests/gui_qt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/gui_qt/conftest.py b/tests/gui_qt/conftest.py new file mode 100644 index 0000000..a737785 --- /dev/null +++ b/tests/gui_qt/conftest.py @@ -0,0 +1,91 @@ +"""Fixtures for the Qt (PySide6) frontend test suite - workstream 3a of +PLAN_qt_and_engine_abstraction.md. + +`pytest.importorskip` at the top means this whole tree self-skips when the +optional `requirements-qt.txt`/`requirements-qt-test.txt` extras aren't +installed, same pattern `tests/conftest.py`'s `espeak_available()` uses for +the integration suite - `pytest` (no args) stays runnable for Tk-only +contributors who never `pip install`ed PySide6. +""" +import os + +import pytest + +pytest.importorskip("PySide6") +pytest.importorskip("pytestqt") + +# Qt needs a platform plugin even to construct widgets; "offscreen" needs no +# real display, so the suite runs the same way in this sandbox, in CI, and on +# a dev machine with no monitor attached. Set before any PySide6 import. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +# tests/ has an __init__.py (package import mode), so this is tests/conftest.py's +# StubEngine, not a name collision with this file (also called conftest.py). +from tests.conftest import StubEngine # noqa: E402 + + +@pytest.fixture +def qt_app(tmp_path, monkeypatch, qtbot): + import kokoro_gui.qt.app as qt_app_module + from PySide6.QtWidgets import QFileDialog, QInputDialog, QMessageBox + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(qt_app_module, "CONFIG_FILE", str(tmp_path / "config_qt.json")) + monkeypatch.setattr(qt_app_module, "PRESETS_DIR", str(tmp_path / "presets")) + monkeypatch.setattr(qt_app_module, "FX_PRESETS_DIR", str(tmp_path / "presets" / "fx")) + monkeypatch.setattr(qt_app_module, "DOCUMENT_FILE", str(tmp_path / "document.json")) + monkeypatch.setattr(qt_app_module, "KokoroEngine", StubEngine) + (tmp_path / "custom_voices").mkdir(exist_ok=True) + + # Modal dialogs (QMessageBox.exec/QInputDialog.exec/...) block on the + # "offscreen" platform exactly like they would on a real display - patch + # the statics globally (same class object every dock module imports) so + # no test hangs waiting for a click that can never happen. Individual + # tests can re-monkeypatch a specific return value (e.g. a preset name) + # on top of this, since `monkeypatch` is shared across one test's fixtures. + monkeypatch.setattr(QMessageBox, "information", staticmethod(lambda *a, **k: None)) + monkeypatch.setattr(QMessageBox, "critical", staticmethod(lambda *a, **k: None)) + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: None)) + monkeypatch.setattr(QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)) + monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("", False))) + monkeypatch.setattr(QFileDialog, "getOpenFileName", staticmethod(lambda *a, **k: ("", ""))) + monkeypatch.setattr(QFileDialog, "getExistingDirectory", staticmethod(lambda *a, **k: "")) + + # A dirty project asks Save / Discard / Cancel on close and before New + # or Open (grill TB12); nearly every test leaves edits behind, so the + # fixture answers Discard. A test about the prompt re-patches this. + monkeypatch.setattr(qt_app_module.QtTTSApp, "_ask_close_choice", lambda self: "discard") + + app = qt_app_module.QtTTSApp() + qtbot.addWidget(app) + yield app + app.wait_for_project_io() + app.close() + + +@pytest.fixture +def make_tagged_document(): + """Factory for a `kokoro_gui.daw.models.Document` whose clips are placed + at specific text offsets - a test-only convenience, since `Document` + itself has no offsets to set directly any more + (Claude/PLAN_text_editor_redesign.md's run-list rework). Pass + `tagged_ranges` as `[(start, end, clip), ...]`; everything else forwards + straight to `Document(...)`.""" + from kokoro_gui.daw.models import Document, Run + + def _make(text: str, tagged_ranges=(), **kwargs): + runs = [] + cursor = 0 + for start, end, clip in sorted(tagged_ranges, key=lambda t: t[0]): + if start > cursor: + runs.append(Run(text=text[cursor:start])) + runs.append(Run(text=text[start:end], clip_id=clip.id, kind=clip.source)) + cursor = end + if cursor < len(text): + runs.append(Run(text=text[cursor:])) + clips = kwargs.pop("clips", None) + if clips is None: + clips = [clip for _start, _end, clip in tagged_ranges] + return Document(runs=runs, clips=clips, **kwargs) + + return _make diff --git a/tests/gui_qt/test_auto_split_generate.py b/tests/gui_qt/test_auto_split_generate.py new file mode 100644 index 0000000..d89f760 --- /dev/null +++ b/tests/gui_qt/test_auto_split_generate.py @@ -0,0 +1,133 @@ +"""Tests for `QtTTSApp.auto_split_and_generate` (item 7, "Auto-split on +generation + combined-vs-separate clip generation", of the DAW-for-text +remaining-work roadmap). Mirrors tests/gui_qt/test_timeline_batch_generate.py's +conventions for driving generation and checking `engine.generate_dirty_clips`.""" +from PySide6.QtWidgets import QMessageBox + +from kokoro_gui.daw.models import Character + + +def _add_bob(qt_app) -> Character: + """`qt_app.document` already has exactly one "Default" character + (migration.py's fallback) - most tests here want a second, distinctly- + named one so untagged-narration auto-clipping (Decision 2: only with + EXACTLY ONE character) doesn't interfere with span-based assertions.""" + bob = Character.from_preset_dict("Bob", {"voice": "am_michael"}) + qt_app.document.characters.append(bob) + return bob + + +def _two_speaker_text() -> str: + return "[Default]: Hello there.\n\n[Bob]: Hi, how are you?" + + +# -- basic clip creation + batch-generate hookup ----------------------------- + + +def test_auto_split_and_generate_creates_one_clip_per_span_and_calls_batch_generate(qt_app): + bob = _add_bob(qt_app) + default_character = qt_app.document.characters[0] + qt_app.document.text = _two_speaker_text() + + qt_app.auto_split_and_generate() + + assert len(qt_app.document.clips) == 2 + character_ids = {c.character_id for c in qt_app.document.clips} + assert character_ids == {default_character.id, bob.id} + assert qt_app.engine.generate_dirty_clips.called + + +def test_auto_split_and_generate_is_noop_and_informs_when_nothing_to_split(qt_app, monkeypatch): + info_calls = [] + monkeypatch.setattr(QMessageBox, "information", staticmethod(lambda *a, **k: info_calls.append(a))) + # Two characters (Default + Bob) + fully untagged text -> Decision 2 says + # no auto-clipping (ambiguous which of two characters to use). + _add_bob(qt_app) + qt_app.document.text = "Untagged narration, no tags anywhere." + + qt_app.auto_split_and_generate() + + assert qt_app.document.clips == [] + assert not qt_app.engine.generate_dirty_clips.called + assert info_calls + + +# -- undo ---------------------------------------------------------------------- + + +def test_auto_split_and_generate_is_undoable(qt_app): + _add_bob(qt_app) + qt_app.document.text = _two_speaker_text() + + qt_app.auto_split_and_generate() + + assert len(qt_app.document.clips) == 2 + assert qt_app.document.undo_stack.can_undo() is True + + qt_app.undo() + qt_app.undo() + + assert qt_app.document.clips == [] + + +# -- one-job-at-a-time guard --------------------------------------------------- + + +def test_auto_split_and_generate_blocked_while_a_job_is_already_running(qt_app): + _add_bob(qt_app) + qt_app.document.text = _two_speaker_text() + qt_app.transport_dock.set_busy(True) # simulate a running job + + qt_app.auto_split_and_generate() + + assert qt_app.document.clips == [] + assert not qt_app.engine.generate_dirty_clips.called + + +# -- unmatched tag names -------------------------------------------------------- + + +def test_unmatched_tag_name_warns_but_still_processes_matched_spans(qt_app, monkeypatch): + warn_calls = [] + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: warn_calls.append(a))) + bob = _add_bob(qt_app) + qt_app.document.text = "[Carol]: Nobody matches this name.\n\n[Bob]: This one matches." + + qt_app.auto_split_and_generate() + + assert warn_calls # the unmatched-name dialog fired + assert len(qt_app.document.clips) == 1 + assert qt_app.document.clips[0].character_id == bob.id + assert qt_app.engine.generate_dirty_clips.called + + +# -- auto_split_by_paragraph checkbox changes behavior end-to-end ------------- + + +def test_auto_split_by_paragraph_checkbox_produces_finer_clips(qt_app): + _add_bob(qt_app) + text = "[Bob]: First paragraph.\n\nSecond paragraph.\n\nThird paragraph." + qt_app.document.text = text + + assert qt_app.transport_dock.split_paragraph_action.isChecked() is False + qt_app.auto_split_and_generate() + coarse_count = len(qt_app.document.clips) + assert coarse_count == 1 + + while qt_app.document.undo_stack.can_undo(): + qt_app.undo() + assert qt_app.document.clips == [] + # generate_dirty_clips_requested() left the one-job-at-a-time guard + # engaged (its dispatched future never resolves in this StubEngine-backed + # test) - reset it so the second auto_split_and_generate() call below + # isn't blocked by the first one's still-"running" job. + qt_app.transport_dock.set_busy(False) + + qt_app.transport_dock.split_paragraph_action.setChecked(True) + assert qt_app.settings["auto_split_by_paragraph"] is True + qt_app.document.text = text + qt_app.auto_split_and_generate() + fine_count = len(qt_app.document.clips) + + assert fine_count > coarse_count + assert fine_count == 3 diff --git a/tests/gui_qt/test_document_state.py b/tests/gui_qt/test_document_state.py new file mode 100644 index 0000000..5a275b8 --- /dev/null +++ b/tests/gui_qt/test_document_state.py @@ -0,0 +1,42 @@ +"""Tests for kokoro_gui/qt/document_state.py's load-or-create logic. Plain +tmp_path/monkeypatch - no need for the full qt_app fixture since this module +has no Qt imports.""" +import json + +from kokoro_gui.daw.serialization import save_document +from kokoro_gui.daw.migration import DEFAULT_CHARACTER_NAME +from kokoro_gui.daw.models import Character, Document +from kokoro_gui.qt.document_state import load_or_create_document + + +def test_migrates_when_no_document_file_exists(tmp_path): + presets_dir = tmp_path / "presets" + presets_dir.mkdir() + (presets_dir / "Alice.json").write_text(json.dumps({"voice": "af_bella"}), encoding="utf-8") + + doc = load_or_create_document(str(tmp_path / "document.json"), {}, str(presets_dir)) + + assert [c.name for c in doc.characters] == ["Alice"] + assert doc.text == "" + + +def test_migrates_seeding_default_character_when_no_presets(tmp_path): + doc = load_or_create_document( + str(tmp_path / "document.json"), {"voice": "af_bella"}, str(tmp_path / "presets") + ) + assert doc.characters[0].name == DEFAULT_CHARACTER_NAME + + +def test_loads_existing_document_verbatim_without_remigrating(tmp_path): + document_path = tmp_path / "document.json" + existing = Document.from_plain_text("hello world", characters=[Character.from_preset_dict("Saved", {})]) + save_document(existing, str(document_path)) + + presets_dir = tmp_path / "presets" + presets_dir.mkdir() + (presets_dir / "SomeoneNew.json").write_text(json.dumps({"voice": "af_bella"}), encoding="utf-8") + + doc = load_or_create_document(str(document_path), {}, str(presets_dir)) + + assert doc.text == "hello world" + assert [c.name for c in doc.characters] == ["Saved"] diff --git a/tests/gui_qt/test_export_and_characters.py b/tests/gui_qt/test_export_and_characters.py new file mode 100644 index 0000000..87fc189 --- /dev/null +++ b/tests/gui_qt/test_export_and_characters.py @@ -0,0 +1,210 @@ +"""Tests for the Export dialog (section 6) and the Edit > Characters dialog +(UI13) of Claude/PLAN_ui_shell_redesign.md, plus the transport -> playhead +-> transcript follow chain (section 5) at the app level.""" +import os + +import numpy as np +import soundfile as sf +from PySide6.QtGui import QTextCursor +from PySide6.QtWidgets import QMessageBox + +from kokoro_gui.daw.dirty import build_segments_from_results, compute_expected_cache_hash + +import kokoro_gui.qt.app # noqa: F401 - app.py must load before any docks module (circular import) +from kokoro_gui.qt.characters_dialog import CharactersDialog # noqa: E402 +from kokoro_gui.qt.docks.export_dialog import ExportDialog, export_defaults, run_export # noqa: E402 + + +def _type(editor, text): + cursor = editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(text) + + +def _generated_clip(qt_app, tmp_path, start, end, seconds=1.0, name="a"): + alice = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(start, end, alice.id) + path = str(tmp_path / f"{name}.wav") + sf.write(path, np.full(int(24000 * seconds), 0.25, dtype=np.float32), 24000) + text = qt_app.document.clip_text(clip) + expected = compute_expected_cache_hash(text, qt_app.document.effective_config_for_clip(clip)) + clip.segments = build_segments_from_results(expected, [{"text": text, "path": path, "duration": seconds}]) + return clip + + +# -- export ------------------------------------------------------------------------- + + +def test_export_defaults_fall_back_to_legacy_settings_then_project(qt_app): + qt_app.settings["out_dir"] = "legacy_dir" + qt_app.settings["export_subtitles"] = True + assert export_defaults(qt_app)["out_dir"] == "legacy_dir" + assert export_defaults(qt_app)["srt"] is True + + qt_app.project_settings["export"] = {"out_dir": "proj_dir", "format": "flac"} + values = export_defaults(qt_app) + assert values["out_dir"] == "proj_dir" and values["format"] == "flac" + + +def test_export_dialog_reads_back_sanitized_values(qt_app): + dialog = ExportDialog(qt_app) + dialog.out_dir_edit.setText("out") + dialog.filename_edit.setText("../../evil") + dialog.format_combo.setCurrentText("flac") + dialog.srt_check.setChecked(True) + dialog.keep_clips_check.setChecked(True) + + values = dialog.values() + + assert values == {"out_dir": "out", "filename": "evil", "format": "flac", "srt": True, "keep_clip_files": True} + + +def test_run_export_refuses_without_clips(qt_app, monkeypatch): + infos = [] + monkeypatch.setattr(QMessageBox, "information", staticmethod(lambda *a, **k: infos.append(a))) + assert run_export(qt_app, export_defaults(qt_app)) is False + assert infos + + +def test_run_export_with_dirty_clips_offers_generate_first(qt_app, monkeypatch): + _type(qt_app.editor, "hello world") + alice = qt_app.document.characters[0] + qt_app.document.assign_character_to_range(0, 11, alice.id) # dirty + generated = [] + monkeypatch.setattr(qt_app, "on_generate_clicked", lambda: generated.append(True)) + monkeypatch.setattr(QMessageBox, "exec", lambda self: None) + monkeypatch.setattr(QMessageBox, "clickedButton", + lambda self: next(b for b in self.buttons() if b.text() == "Generate first")) + + assert run_export(qt_app, export_defaults(qt_app)) is False + assert generated == [True] + + +def test_run_export_schedules_mixdown_on_the_worker_and_writes_the_file(qt_app, tmp_path): + _type(qt_app.editor, "hello world") + _generated_clip(qt_app, tmp_path, 0, 5, seconds=1.0, name="a") + _generated_clip(qt_app, tmp_path, 6, 11, seconds=0.5, name="b") + values = {"out_dir": str(tmp_path / "out"), "filename": "mix", "format": "wav", "srt": True, + "keep_clip_files": True} + + assert run_export(qt_app, values) is True + assert qt_app.is_busy() + assert qt_app.project_settings["export"] == values + + coro = qt_app.engine.worker.run_coro.call_args[0][0] + import asyncio + + result = asyncio.run(coro) + assert os.path.exists(result.audio_path) + data, rate = sf.read(result.audio_path) + assert rate == 24000 and len(data) == 36000 + assert result.srt_path.endswith("mix.srt") + assert len(result.clip_files) == 2 + + # StubEngine hands out a real Future; resolving it runs run_export's + # done-callback (the worker thread in real life), which emits + # exportFinished back onto the GUI thread. + future = qt_app.engine.worker.run_coro.return_value + future.set_result(result) + assert not qt_app.is_busy() + assert "Exported" in qt_app.transport_dock.status_text() + + +# -- characters dialog ------------------------------------------------------------------ + + +def test_characters_dialog_lists_and_edits_name_color_voice_fx(qt_app): + alice = qt_app.document.characters[0] + dialog = CharactersDialog(qt_app) + assert [dialog.list.item(i).text() for i in range(dialog.list.count())] == ["Default"] + + dialog.name_edit.setText("Narrator") + dialog.name_edit.textEdited.emit("Narrator") + dialog.set_color("#123456") + dialog.voice_combo.setCurrentText("af_bella") + dialog.fx_combo.addItem("Echo") + dialog.fx_combo.setCurrentText("Echo") + + assert alice.name == "Narrator" + assert alice.highlight_color == "#123456" + assert alice.preset_data["voice"] == "af_bella" + assert alice.preset_data["fx_preset"] == "Echo" + assert qt_app.document.tracks[0].name == "Narrator" + assert qt_app.transcript_dock.character_combo.findText("Narrator") >= 0 + + +def test_characters_dialog_add_and_remove(qt_app, monkeypatch): + dialog = CharactersDialog(qt_app) + new = dialog.add_character() + assert new in qt_app.document.characters + assert any(t.character_id == new.id for t in qt_app.document.tracks) + + dialog.remove_current() + assert new not in qt_app.document.characters + assert not any(t.character_id == new.id for t in qt_app.document.tracks) + + +def test_characters_dialog_refuses_to_remove_a_character_in_use(qt_app, monkeypatch): + warned = [] + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: warned.append(a))) + _type(qt_app.editor, "hello") + alice = qt_app.document.characters[0] + qt_app.document.assign_character_to_range(0, 5, alice.id) + dialog = CharactersDialog(qt_app) + + dialog.remove_current() + + assert alice in qt_app.document.characters + assert warned + + +# -- transport follow --------------------------------------------------------------------- + + +def test_transport_position_drives_playhead_readout_and_playing_clip(qt_app, tmp_path): + _type(qt_app.editor, "hello world") + first = _generated_clip(qt_app, tmp_path, 0, 5, seconds=1.0, name="a") + second = _generated_clip(qt_app, tmp_path, 6, 11, seconds=1.0, name="b") + qt_app._rebuild_transport_schedule() + assert qt_app.transport.duration() == 2.0 + + qt_app.transport._set_state("playing") + qt_app._on_transport_position(0.5) + assert qt_app.selection.playing_clip_id == first.id + assert "00:00.5 / 00:02.0" == qt_app.transport_dock.time_label.text() + assert qt_app.timeline_dock.timeline_view._playhead_item.isVisible() + + qt_app._on_transport_position(1.5) + assert qt_app.selection.playing_clip_id == second.id + assert qt_app.selection.selected_clip_id is None + + qt_app._on_transport_state("stopped") + assert qt_app.selection.playing_clip_id is None + + +def test_generation_finishing_reloads_the_transport(qt_app, tmp_path, monkeypatch): + reloads = [] + monkeypatch.setattr(qt_app.transport, "load", lambda *a, **k: reloads.append(True)) + qt_app.on_batch_generation_finished(1, 0, []) + qt_app.on_engine_finish() + assert reloads == [True, True] + + +def test_ruler_seek_moves_the_transport(qt_app, tmp_path): + _type(qt_app.editor, "hello world") + _generated_clip(qt_app, tmp_path, 0, 11, seconds=3.0, name="a") + qt_app._rebuild_transport_schedule() + + qt_app.timeline_dock.timeline_view.seekRequested.emit(1.25) + + assert abs(qt_app.transport.position() - 1.25) < 1e-6 + assert qt_app.transport_dock.time_label.text().startswith("00:01.2") + + +def test_characters_changed_refreshes_header_and_timeline(qt_app): + dialog = CharactersDialog(qt_app) + added = dialog.add_character() + assert qt_app.transcript_dock.character_combo.findData(added.id) >= 0 + labels = [item.text() for item in qt_app.timeline_dock.timeline_widget.header._scene.items() + if hasattr(item, "text")] + assert added.name in labels diff --git a/tests/gui_qt/test_fx_dock_scope.py b/tests/gui_qt/test_fx_dock_scope.py new file mode 100644 index 0000000..6e4a08e --- /dev/null +++ b/tests/gui_qt/test_fx_dock_scope.py @@ -0,0 +1,181 @@ +"""Tests for the Audio FX tab's selection scoping (UI6, section 3 of +Claude/PLAN_ui_shell_redesign.md): project / character / clip modes, the +debounced clip override, the character-preset confirmation, and the +timeline FX button raising the tab.""" +import json +import os + +from PySide6.QtGui import QTextCursor +from PySide6.QtWidgets import QMessageBox + +from kokoro_gui.daw.models import Character + + +def _type(editor, text): + cursor = editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(text) + + +def _write_fx_preset(qt_app, name, values): + import kokoro_gui.qt.app as qt_app_module + + os.makedirs(qt_app_module.FX_PRESETS_DIR, exist_ok=True) + with open(os.path.join(qt_app_module.FX_PRESETS_DIR, f"{name}.json"), "w", encoding="utf-8") as f: + json.dump(values, f) + qt_app.fx_dock.refresh_presets() + + +def _clip_for(qt_app, character, text="hello world"): + _type(qt_app.editor, text) + clip = qt_app.document.assign_character_to_range(0, len(text), character.id) + qt_app.editor.rehighlight() + return clip + + +def test_starts_in_project_mode_and_follows_selection(qt_app): + fx = qt_app.fx_dock + assert fx.mode == "none" + alice = qt_app.document.characters[0] + clip = _clip_for(qt_app, alice) + + qt_app.selection.select_clip(clip.id) + assert fx.mode == "clip" + qt_app.selection.select_character(alice.id) + assert fx.mode == "character" + qt_app.selection.clear() + assert fx.mode == "none" + + +def test_project_mode_edits_stay_project_wide(qt_app): + fx = qt_app.fx_dock + fx._value_widgets["gain_db"].setValue(4.0) + assert fx.project_fx_state()["gain_db"] == 4.0 + assert qt_app._assemble_config()["gain_db"] == 4.0 + + +def test_clip_mode_shows_the_resolved_stack_and_edits_become_one_undoable_override(qt_app): + fx = qt_app.fx_dock + _write_fx_preset(qt_app, "Warm", {"eq_bass": 3.0}) + warm = Character.from_preset_dict("Warm", {"fx_preset": "Warm"}) + qt_app.document.characters.append(warm) + clip = _clip_for(qt_app, warm) + fx._value_widgets["gain_db"].setValue(2.0) # project value, inherited below + + qt_app.selection.select_clip(clip.id) + + assert fx.mode == "clip" + assert fx._value_widgets["eq_bass"].value() == 3.0 # from the character's preset + assert fx._value_widgets["gain_db"].value() == 2.0 # from the project state + assert fx.preset_combo.currentText() == "Warm" + + fx._value_widgets["eq_treble"].setValue(5.0) + fx._value_widgets["eq_treble"].setValue(6.0) + fx._flush_clip_edit() + + assert clip.fx_override["eq_treble"] == 6.0 + assert clip.fx_override["eq_bass"] == 3.0 # the whole resolved dict is the override + assert fx.preset_combo.currentText() == "(custom)" + assert fx.project_fx_state()["eq_treble"] == 0.0 # project untouched + qt_app.undo() + assert clip.fx_override is None + + +def test_character_mode_writes_the_preset_file_after_confirming_once(qt_app, monkeypatch): + fx = qt_app.fx_dock + _write_fx_preset(qt_app, "Warm", {"eq_bass": 3.0}) + warm = Character.from_preset_dict("Warm", {"fx_preset": "Warm"}) + qt_app.document.characters.append(warm) + asked = [] + + def _question(*args, **kwargs): + asked.append(args[2]) + return QMessageBox.StandardButton.Yes + + monkeypatch.setattr(QMessageBox, "question", staticmethod(_question)) + qt_app.selection.select_character(warm.id) + assert fx.mode == "character" + + fx._value_widgets["eq_bass"].setValue(-2.0) + fx._value_widgets["eq_treble"].setValue(1.5) + + import kokoro_gui.qt.app as qt_app_module + + with open(os.path.join(qt_app_module.FX_PRESETS_DIR, "Warm.json"), encoding="utf-8") as f: + saved = json.load(f) + assert saved["eq_bass"] == -2.0 and saved["eq_treble"] == 1.5 + assert len(asked) == 1 and "Warm" in asked[0] # confirmed once per session + + +def test_character_mode_declined_confirmation_reverts_the_widget(qt_app, monkeypatch): + fx = qt_app.fx_dock + _write_fx_preset(qt_app, "Warm", {"eq_bass": 3.0}) + warm = Character.from_preset_dict("Warm", {"fx_preset": "Warm"}) + qt_app.document.characters.append(warm) + monkeypatch.setattr(QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)) + qt_app.selection.select_character(warm.id) + + fx._value_widgets["eq_bass"].setValue(-2.0) + + assert fx._value_widgets["eq_bass"].value() == 3.0 + + +def test_character_without_preset_gets_one_named_after_it(qt_app, monkeypatch): + fx = qt_app.fx_dock + bare = Character.from_preset_dict("Bare", {}) + qt_app.document.characters.append(bare) + monkeypatch.setattr(QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)) + qt_app.selection.select_character(bare.id) + + fx._value_widgets["gain_db"].setValue(1.0) + + import kokoro_gui.qt.app as qt_app_module + + assert bare.preset_data["fx_preset"] == "Bare" + assert os.path.exists(os.path.join(qt_app_module.FX_PRESETS_DIR, "Bare.json")) + + +def test_preset_combo_in_clip_mode_applies_a_named_override(qt_app): + fx = qt_app.fx_dock + _write_fx_preset(qt_app, "Telephone", {"highpass_enabled": True, "highpass_freq": 300.0}) + qt_app.engine.load_fx_preset.return_value = {"highpass_enabled": True, "highpass_freq": 300.0} + alice = qt_app.document.characters[0] + clip = _clip_for(qt_app, alice) + qt_app.selection.select_clip(clip.id) + + index = fx.preset_combo.findText("Telephone") + fx._on_preset_activated(index) + + assert clip.overrides["fx_preset"] == "Telephone" + assert clip.fx_override["highpass_freq"] == 300.0 + assert fx.preset_combo.currentText() == "Telephone" + + +def test_timeline_fx_button_selects_the_clip_and_raises_the_tab(qt_app): + _write_fx_preset(qt_app, "Telephone", {"highpass_enabled": True}) + qt_app.engine.load_fx_preset.return_value = {"highpass_enabled": True} + alice = qt_app.document.characters[0] + clip = _clip_for(qt_app, alice) + raised = [] + qt_app.raise_fx_tab = lambda: raised.append(True) + + qt_app.timeline_dock.on_fx_preset_requested(clip.id, "Telephone") + + assert qt_app.selection.selected_clip_id == clip.id + assert raised == [True] + assert qt_app.fx_dock.mode == "clip" + assert clip.overrides["fx_preset"] == "Telephone" + + +def test_get_state_reflects_widgets_while_project_state_survives_scope_changes(qt_app): + fx = qt_app.fx_dock + fx._value_widgets["gain_db"].setValue(7.0) + alice = qt_app.document.characters[0] + clip = _clip_for(qt_app, alice) + clip.fx_override = {"gain_db": -3.0} + qt_app.selection.select_clip(clip.id) + + assert fx.get_state()["gain_db"] == -3.0 + assert fx.project_fx_state()["gain_db"] == 7.0 + qt_app.selection.clear() + assert fx.get_state()["gain_db"] == 7.0 diff --git a/tests/gui_qt/test_nondestructive_fx.py b/tests/gui_qt/test_nondestructive_fx.py new file mode 100644 index 0000000..2426ef6 --- /dev/null +++ b/tests/gui_qt/test_nondestructive_fx.py @@ -0,0 +1,155 @@ +"""FX are read-time post-processing (kokoro_gui/audio/post.py + +kokoro_gui/qt/fx_resolve.py): changing them re-renders what the transport +plays and never dirties the clip. These tests seed a "generated" clip with +a real raw wav and check the transport/arrangement side, with StubEngine +so nothing is synthesized.""" +import numpy as np +import soundfile as sf + +from kokoro_gui.daw.dirty import compute_expected_cache_hash +from kokoro_gui.daw.models import Segment +from kokoro_gui.daw.undo import SetClipFxCommand + +RATE = 24000 + + +def _generated_clip(qt_app, tmp_path, text="hello world", seconds=0.2, amplitude=0.25, name="seg"): + qt_app.document.text = text + character = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(0, len(text), character.id) + path = tmp_path / f"{name}.wav" + sf.write(str(path), np.full(int(RATE * seconds), amplitude, dtype=np.float32), RATE) + config = qt_app._assemble_clip_config(clip) + expected_hash = compute_expected_cache_hash(qt_app.document.clip_text(clip), config) + clip.segments = [Segment(order_index=0, text=text, cache_key=expected_hash, + audio_path=str(path), duration=seconds)] + return clip + + +def _transport_samples(qt_app): + qt_app._rebuild_transport_schedule() + return [c.samples for c in qt_app.transport.loaded_clips()] + + +def test_clip_fx_override_is_audible_without_dirtying_the_clip(qt_app, tmp_path): + clip = _generated_clip(qt_app, tmp_path) + assert qt_app.document.dirty_clips() == [] + before = _transport_samples(qt_app)[0] + + qt_app.document.undo_stack.push(SetClipFxCommand(clip.id, {"gain_enabled": True, "gain_db": 6.0})) + + assert qt_app.document.dirty_clips() == [] # FX never require generation + after = _transport_samples(qt_app)[0] + assert clip.segments[0].audio_path.endswith("seg.wav") # segment untouched + assert np.max(np.abs(after)) > np.max(np.abs(before)) * 1.5 + # The raw file on disk is what it was. + on_disk, _ = sf.read(clip.segments[0].audio_path, dtype="float32") + assert np.array_equal(on_disk, before) + + +def test_project_scope_fx_reach_a_clip_with_no_character_preset(qt_app, tmp_path): + clip = _generated_clip(qt_app, tmp_path) + before = _transport_samples(qt_app)[0] + + qt_app.fx_dock._value_widgets["gain_db"].setValue(6.0) + qt_app.fx_dock._enabled_checks["gain_enabled"].setChecked(True) + + config = qt_app._assemble_clip_config(clip) + assert config["gain_enabled"] is True and config["gain_db"] == 6.0 + after = _transport_samples(qt_app)[0] + assert np.max(np.abs(after)) > np.max(np.abs(before)) * 1.5 + assert qt_app.document.dirty_clips() == [] + + +def test_project_scope_fx_edit_schedules_a_timeline_refresh(qt_app, tmp_path, qtbot): + _generated_clip(qt_app, tmp_path) + calls = [] + qt_app.refresh_timeline = lambda: calls.append(True) + + qt_app.fx_dock._value_widgets["gain_db"].setValue(3.0) + + assert qt_app.fx_dock._project_timer.isActive() + qtbot.waitUntil(lambda: bool(calls), timeout=2000) + + +def test_clip_volume_edit_is_post_processing(qt_app, tmp_path): + """The Settings tab's volume in clip scope (a `clip.overrides` write; + the seeded Default character carries its own volume, so project scope + wouldn't reach this clip - Q7 layering, not an FX matter).""" + clip = _generated_clip(qt_app, tmp_path) + before = _transport_samples(qt_app)[0] + + qt_app.selection.select_clip(clip.id) + qt_app.settings_dock.volume_spin.setValue(0.5) + + assert clip.overrides["volume"] == 0.5 + after = _transport_samples(qt_app)[0] + assert np.allclose(after, before * 0.5, atol=1e-6) + assert qt_app.document.dirty_clips() == [] + + +def test_master_apply_fx_off_silences_the_chain_but_not_volume(qt_app, tmp_path): + clip = _generated_clip(qt_app, tmp_path) + before = _transport_samples(qt_app)[0] + qt_app.document.undo_stack.push(SetClipFxCommand(clip.id, {"gain_enabled": True, "gain_db": 6.0})) + + qt_app.settings_dock.apply_fx_check.setChecked(False) + + assert qt_app._assemble_clip_config(clip)["apply_fx"] is False + assert np.array_equal(_transport_samples(qt_app)[0], before) + + +def test_clip_fx_override_turns_fx_on_even_if_the_character_preset_says_off(qt_app, tmp_path): + clip = _generated_clip(qt_app, tmp_path) + qt_app.document.characters[0].preset_data["apply_fx"] = False + assert qt_app._assemble_clip_config(clip)["apply_fx"] is False + + qt_app.document.undo_stack.push(SetClipFxCommand(clip.id, {"gain_enabled": True, "gain_db": 6.0})) + assert qt_app._assemble_clip_config(clip)["apply_fx"] is True + + clip.overrides["apply_fx"] = False # an explicit clip-level off still wins + assert qt_app._assemble_clip_config(clip)["apply_fx"] is False + + +def test_arrangement_measures_the_rendered_length(qt_app, tmp_path): + text = "hello world" + qt_app.document.text = text + character = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(0, len(text), character.id) + path = tmp_path / "padded.wav" + silence = np.zeros(RATE // 2, dtype=np.float32) + tone = np.full(RATE // 2, 0.3, dtype=np.float32) + sf.write(str(path), np.concatenate([silence, tone, silence]), RATE) + config = qt_app._assemble_clip_config(clip) + clip.segments = [Segment(order_index=0, text=text, audio_path=str(path), duration=1.5, + cache_key=compute_expected_cache_hash(text, config))] + + placed = qt_app.build_arrangement().by_clip_id()[clip.id] + assert placed.duration_s == 1.5 and not placed.estimated + + qt_app.selection.select_clip(clip.id) + qt_app.settings_dock.trim_check.setChecked(True) + assert clip.overrides["trim"] is True + placed = qt_app.build_arrangement().by_clip_id()[clip.id] + assert placed.duration_s == 0.5 + assert qt_app.document.dirty_clips() == [] + + +def test_legacy_baked_segment_is_dirty_and_a_fresh_one_is_not(qt_app, tmp_path): + clip = _generated_clip(qt_app, tmp_path) + assert qt_app.document.dirty_clips() == [] + clip.segments[0].raw = False + assert [c.id for c in qt_app.document.dirty_clips()] == [clip.id] + + +def test_fx_dock_clip_scope_shows_what_the_config_plays(qt_app, tmp_path): + clip = _generated_clip(qt_app, tmp_path) + qt_app.fx_dock._value_widgets["gain_db"].setValue(2.0) # project layer + qt_app.document.undo_stack.push(SetClipFxCommand(clip.id, {"eq_bass": 3.0})) + + qt_app.selection.select_clip(clip.id) + + assert qt_app.fx_dock.mode == "clip" + config = qt_app._assemble_clip_config(clip) + assert qt_app.fx_dock._value_widgets["gain_db"].value() == config["gain_db"] == 2.0 + assert qt_app.fx_dock._value_widgets["eq_bass"].value() == config["eq_bass"] == 3.0 diff --git a/tests/gui_qt/test_project_files.py b/tests/gui_qt/test_project_files.py new file mode 100644 index 0000000..9cd8c70 --- /dev/null +++ b/tests/gui_qt/test_project_files.py @@ -0,0 +1,964 @@ +"""Tests for the File menu / project lifecycle and kokoro_gui/qt/project.py: +the `.tbaw` bundle (Claude/old/PLAN_tbaw_bundle.md, grill TB1-TB15).""" +import json +import os +import sys +import zipfile + +import pytest +from PySide6.QtGui import QTextCursor + +from kokoro_gui.daw.dirty import build_segments_from_results +from kokoro_gui.daw.models import Character, Clip, Document, Run, Segment +from kokoro_gui.qt import project as project_io + + +def _type(editor, text): + cursor = editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(text) + + +def _save_as(qt_app, path): + qt_app.save_project_as(path) + qt_app.wait_for_project_io() + return qt_app.project_path + + +def _open(qt_app, path): + qt_app.open_project(path) + qt_app.wait_for_project_io() + + +def _simulate_crash(qt_app): + """The process died: the OS released the lock, the dir stays as it was + (dirty), and the next window starts with no project.""" + qt_app._project_lock.release() + qt_app._project_lock = None + qt_app.project_dir = None + qt_app.project_id = None + qt_app.project_path = None + + +def _generated_clip(qt_app, text="hello world", seconds=0.1): + """A clean clip whose one segment is a real raw wav inside the project + dir, named by its key, the way a generate leaves it.""" + import numpy as np + import soundfile as sf + + qt_app.document.text = text + character = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(0, len(text), character.id) + key = qt_app.document.segment_key_fn(text, clip) + generated = os.path.join(qt_app.project_dir, "audio", "generated") + os.makedirs(generated, exist_ok=True) + path = os.path.join(generated, f"{key}_0.wav") + sf.write(path, np.full(int(24000 * seconds), 0.2, dtype=np.float32), 24000) + clip.segments = build_segments_from_results(key, [{"text": text, "path": path, "duration": seconds, + "cache_key": key, + "engine_version": qt_app.backend.engine_version()}]) + return clip + + +# -- project.py: recent list --------------------------------------------------------- + + +def test_remember_recent_moves_to_front_dedupes_and_caps(tmp_path): + settings = {} + for i in range(12): + project_io.remember_recent(settings, str(tmp_path / f"p{i}.tbaw")) + project_io.remember_recent(settings, str(tmp_path / "p3.tbaw")) + + recent = settings["recent_projects"] + assert len(recent) == project_io.MAX_RECENT + assert recent[0].endswith("p3.tbaw") + assert sum(1 for p in recent if p.endswith("p3.tbaw")) == 1 + assert settings["last_project"].endswith("p3.tbaw") + + +def test_clear_recent_empties_list_but_keeps_last_project(tmp_path): + settings = {} + project_io.remember_recent(settings, str(tmp_path / "a.tbaw")) + project_io.clear_recent(settings) + assert settings["recent_projects"] == [] + assert settings["last_project"].endswith("a.tbaw") + + +def test_new_document_inherits_characters_as_copies(): + alice = Character.from_preset_dict("Alice", {"voice": "af_heart"}) + previous = Document.from_plain_text("old text", characters=[alice]) + + fresh = project_io.new_document_from(previous) + + assert fresh.text == "" + assert [c.name for c in fresh.characters] == ["Alice"] + assert fresh.characters[0] is not alice + assert fresh.tracks[0].character_id == fresh.characters[0].id + + +# -- project.py: the bundle without an app ------------------------------------------- + + +def _document_with_audio(project_dir, text="hello", key="abc"): + generated = os.path.join(project_dir, "audio", "generated") + os.makedirs(generated, exist_ok=True) + seg = os.path.join(generated, f"{key}_0.wav") + with open(seg, "wb") as f: + f.write(b"RIFF" + b"\0" * 60) + character = Character.from_preset_dict("A", {"voice": "af_heart", "fx_preset": "warm"}) + clip = Clip(character_id=character.id, segments=[Segment(0, text, key, seg, 1.5)]) + return Document(runs=[Run(text, clip.id, "generated")], clips=[clip], characters=[character], + settings={"x": 1}), seg + + +def test_bundle_round_trips_document_settings_audio_and_assets(tmp_path, isolated_dirs): + fx_dir = tmp_path / "presets" / "fx" + fx_dir.mkdir(parents=True) + (fx_dir / "warm.json").write_text('{"gain_db": 2.0}', encoding="utf-8") + project_dir, project_id = project_io.create_project_dir() + doc, seg = _document_with_audio(project_dir) + path = str(tmp_path / "proj.tbaw") + + result = project_io.save_project(doc, path, {"export": {"format": "flac"}}, project_dir, project_id, + fx_presets_dir=str(fx_dir)) + + with zipfile.ZipFile(path) as zf: + names = set(zf.namelist()) + manifest = json.loads(zf.read("manifest.json")) + document = json.loads(zf.read("document.json")) + assert names == {"manifest.json", "document.json", "project.json", "audio/generated/abc_0.wav", "fx/warm.json"} + assert manifest["format"] == "tbaw" and manifest["version"] == 1 and manifest["requires"] == [] + assert manifest["project_id"] == project_id + assert manifest["stats"] == {"clips": 1, "characters": 1, "duration_s": 1.5} + assert manifest["assets"]["fx/warm.json"].startswith("sha256:") + assert document["clips"][0]["segments"][0]["audio_path"] == "audio/generated/abc_0.wav" + assert result.asset_index["fx/warm.json"][2] == manifest["assets"]["fx/warm.json"] + assert project_io.read_session(project_dir)["saved_digest"] == result.saved_digest + + # A second extraction elsewhere reads back the same document with + # absolute paths inside its own dir. + info = project_io.inspect_bundle(path) + other_dir = str(tmp_path / "other") + project_io.extract_small(info, other_dir) + project_io.extract_audio(info, other_dir) + loaded = project_io.finish_open(info, other_dir) + assert loaded.document.text == "hello" + assert loaded.project_settings == {"export": {"format": "flac"}} + assert loaded.document.clips[0].segments[0].audio_path == os.path.join(other_dir, "audio", "generated", "abc_0.wav") + assert os.path.isfile(os.path.join(other_dir, "fx", "warm.json")) + assert loaded.notices == [] + + +def test_missing_audio_on_open_leaves_the_segment_pathless_and_says_so(tmp_path, isolated_dirs): + project_dir, project_id = project_io.create_project_dir() + doc, _seg = _document_with_audio(project_dir) + path = str(tmp_path / "proj.tbaw") + project_io.save_project(doc, path, {"bundle": {"include_generated_audio": False}}, project_dir, project_id) + + with zipfile.ZipFile(path) as zf: + assert not any(n.startswith("audio/") for n in zf.namelist()) + assert json.loads(zf.read("manifest.json"))["includes"]["generated_audio"] is False + info = project_io.inspect_bundle(path) + other = str(tmp_path / "other") + project_io.extract_small(info, other) + loaded = project_io.finish_open(info, other) + assert loaded.document.clips[0].segments[0].audio_path is None + assert any("missing" in n for n in loaded.notices) + assert loaded.document.dirty_clips() == [loaded.document.clips[0]] + + +def test_open_drops_an_audio_path_that_points_outside_the_project_dir(tmp_path, isolated_dirs): + """A `document.json` is untrusted input: a segment naming a file + elsewhere on the machine reads as missing rather than as that file, + which the next Save would otherwise copy into the bundle.""" + secret = tmp_path / "secret.txt" + secret.write_bytes(b"not audio") + other = str(tmp_path / "other") + os.makedirs(other) + with open(os.path.join(other, "document.json"), "w", encoding="utf-8") as f: + json.dump({"runs": [], "characters": [], + "clips": [{"id": "c1", "character_id": "a", "segments": [ + {"order_index": 0, "text": "hi", "cache_key": "k", "audio_path": str(secret), + "duration": 1.0}]}]}, f) + info = project_io.BundleInfo(path=str(tmp_path / "x.tbaw"), manifest={}, project_id="x", entries=[], + audio_bytes=0, zip_size=0, zip_mtime=0.0) + loaded = project_io.finish_open(info, other) + assert loaded.document.clips[0].segments[0].audio_path is None + assert any("missing" in n for n in loaded.notices) + assert secret.read_bytes() == b"not audio" + + +def test_save_bundles_only_audio_inside_the_project_dir(tmp_path, isolated_dirs): + project_dir, project_id = project_io.create_project_dir() + doc, _seg = _document_with_audio(project_dir) + outside = tmp_path / "elsewhere.wav" + outside.write_bytes(b"RIFF" + b"\0" * 60) + doc.clips[0].segments[0].audio_path = str(outside) + path = str(tmp_path / "proj.tbaw") + + project_io.save_project(doc, path, {}, project_dir, project_id) + + with zipfile.ZipFile(path) as zf: + assert not any(n.startswith("audio/") for n in zf.namelist()) + document = json.loads(zf.read("document.json")) + assert document["clips"][0]["segments"][0]["audio_path"] == str(outside).replace("\\", "/") + + +def test_open_never_extracts_the_dirs_own_session_or_lock(tmp_path, isolated_dirs): + path = str(tmp_path / "planted.tbaw") + _write_bundle(path, _manifest(), {"session.json": b'{"dirty": true, "source_path": "/elsewhere"}', + "lock": b"x", "session.json.tmp": b"{}"}) + info = project_io.inspect_bundle(path) + project_dir = project_io.choose_project_dir(info.project_id, info.path) + lock = project_io.ProjectLock(project_dir).acquire() + try: + project_io.extract_small(info, project_dir) + loaded = project_io.finish_open(info, project_dir) + finally: + lock.release() + assert loaded.document.text == "" + session = project_io.read_session(project_dir) + assert session["source_path"] == info.path and session["dirty"] is False + assert not os.path.exists(os.path.join(project_dir, "session.json.tmp")) + + +def _write_bundle(path, manifest, extra_entries=None): + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("manifest.json", json.dumps(manifest)) + zf.writestr("document.json", json.dumps({"runs": [], "clips": [], "characters": []})) + for name, data in (extra_entries or {}).items(): + zf.writestr(name, data) + + +def _manifest(**overrides): + base = {"format": "tbaw", "version": 1, "requires": [], "project_id": "0123456789abcdef"} + base.update(overrides) + return base + + +def test_open_rejects_newer_version_and_unknown_requires_by_name(tmp_path, isolated_dirs): + newer = str(tmp_path / "newer.tbaw") + _write_bundle(newer, _manifest(version=2)) + with pytest.raises(project_io.ProjectError, match="newer KokoroGUI"): + project_io.inspect_bundle(newer) + + needs = str(tmp_path / "needs.tbaw") + _write_bundle(needs, _manifest(requires=["chapters"])) + with pytest.raises(project_io.ProjectError, match="chapters"): + project_io.inspect_bundle(needs) + + not_ours = str(tmp_path / "other.tbaw") + _write_bundle(not_ours, {"format": "zip-of-things", "version": 1}) + with pytest.raises(project_io.ProjectError): + project_io.inspect_bundle(not_ours) + + +@pytest.mark.parametrize("name", ["../escape.txt", "/abs/escape.txt", "C:evil.txt", "audio/../../up.txt"]) +def test_open_rejects_zip_slip_and_drive_relative_entries(tmp_path, isolated_dirs, name): + path = str(tmp_path / "bad.tbaw") + _write_bundle(path, _manifest(), {name: b"x"}) + with pytest.raises(project_io.ProjectError, match="Refusing"): + project_io.inspect_bundle(path) + + +def test_open_rejects_symlink_entries(tmp_path, isolated_dirs): + path = str(tmp_path / "link.tbaw") + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("manifest.json", json.dumps(_manifest())) + zf.writestr("document.json", "{}") + info = zipfile.ZipInfo("audio/generated/link.wav") + info.external_attr = (0o120777 << 16) + zf.writestr(info, "../../etc/passwd") + with pytest.raises(project_io.ProjectError, match="symlink"): + project_io.inspect_bundle(path) + + +def test_unknown_entries_and_manifest_keys_survive_open_and_save(tmp_path, isolated_dirs): + path = str(tmp_path / "future.tbaw") + _write_bundle(path, _manifest(future_key={"a": 1}), + {"chapters/ch1.json": b'{"future": true}', "engines/neweng/model.bin": b"\x00\x01"}) + info = project_io.inspect_bundle(path) + assert info.manifest["future_key"] == {"a": 1} + project_dir = project_io.choose_project_dir(info.project_id, info.path) + project_io.extract_small(info, project_dir) + loaded = project_io.finish_open(info, project_dir) + assert os.path.isfile(os.path.join(project_dir, "chapters", "ch1.json")) + + project_io.save_project(loaded.document, path, loaded.project_settings, project_dir, info.project_id, + known_engine_ids=("kokoro", "audio8", "dummy")) + with zipfile.ZipFile(path) as zf: + assert zf.read("chapters/ch1.json") == b'{"future": true}' + assert zf.read("engines/neweng/model.bin") == b"\x00\x01" + + +def test_save_is_atomic_when_the_write_fails(tmp_path, isolated_dirs, monkeypatch): + project_dir, project_id = project_io.create_project_dir() + doc, _seg = _document_with_audio(project_dir) + path = str(tmp_path / "proj.tbaw") + project_io.save_project(doc, path, {}, project_dir, project_id) + before = open(path, "rb").read() + + real_write = zipfile.ZipFile.write + + def _boom(self, *a, **k): + raise OSError("disk on fire") + + monkeypatch.setattr(zipfile.ZipFile, "write", _boom) + with pytest.raises(OSError): + project_io.save_project(doc, path, {}, project_dir, project_id) + monkeypatch.setattr(zipfile.ZipFile, "write", real_write) + + assert open(path, "rb").read() == before + assert not os.path.exists(path + ".tmp") + + +def test_save_and_open_refuse_on_short_disk_before_writing(tmp_path, isolated_dirs, monkeypatch): + project_dir, project_id = project_io.create_project_dir() + doc, _seg = _document_with_audio(project_dir) + path = str(tmp_path / "proj.tbaw") + monkeypatch.setattr(project_io, "free_space", lambda _p: 10) + with pytest.raises(project_io.ProjectError, match="free space"): + project_io.save_project(doc, path, {}, project_dir, project_id) + assert not os.path.exists(path) and not os.path.exists(path + ".tmp") + + monkeypatch.setattr(project_io, "free_space", lambda _p: 10 ** 12) + project_io.save_project(doc, path, {}, project_dir, project_id) + monkeypatch.setattr(project_io, "free_space", lambda _p: 10) + info = project_io.inspect_bundle(path) + with pytest.raises(project_io.ProjectError, match="free space"): + project_io.check_free_space(project_io.projects_root(), info.audio_bytes, "open the project") + + +def test_save_deletes_nothing_in_the_project_dir(tmp_path, isolated_dirs): + project_dir, project_id = project_io.create_project_dir() + doc, seg = _document_with_audio(project_dir) + orphan = os.path.join(project_dir, "audio", "generated", "orphan_0.wav") + open(orphan, "wb").write(b"RIFF") + project_io.save_project(doc, str(tmp_path / "p.tbaw"), {}, project_dir, project_id) + assert os.path.isfile(orphan) and os.path.isfile(seg) + + +def test_close_time_gc_removes_orphans_and_keeps_a_takes_files(tmp_path, isolated_dirs): + project_dir, _project_id = project_io.create_project_dir() + doc, seg = _document_with_audio(project_dir) + generated = os.path.dirname(seg) + orphan = os.path.join(generated, "orphan_0.wav") + take_file = os.path.join(generated, "take2_0.wav") + marker = os.path.join(generated, "stale.reserved") + for p in (orphan, take_file, marker): + open(p, "wb").write(b"RIFF") + other = Clip(segments=[Segment(0, "x", "take2", take_file, 0.5)]) + doc.clips.append(other) + + removed = project_io.gc_project_dir(project_dir, doc) + + assert sorted(os.path.basename(r) for r in removed) == ["orphan_0.wav", "stale.reserved"] + assert os.path.isfile(seg) and os.path.isfile(take_file) + + +def test_eviction_keeps_only_the_last_project_dir_and_skips_dirty_and_locked(isolated_dirs): + keep, _ = project_io.create_project_dir("keep") + gone, _ = project_io.create_project_dir("gone") + dirty, _ = project_io.create_project_dir("dirty") + locked, _ = project_io.create_project_dir("locked") + project_io.write_session(dirty, {"dirty": True, "source_path": None}) + lock = project_io.ProjectLock(locked).acquire() + try: + removed = project_io.evict_project_dirs(keep) + finally: + lock.release() + assert removed == [gone] + assert os.path.isdir(keep) and os.path.isdir(dirty) and os.path.isdir(locked) + assert not os.path.exists(gone) + + +def test_sweep_removes_clean_dirs_whose_file_is_gone(isolated_dirs, tmp_path): + orphan, _ = project_io.create_project_dir("orphan") + project_io.write_session(orphan, {"dirty": False, "source_path": str(tmp_path / "moved.tbaw")}) + kept, _ = project_io.create_project_dir("kept") + existing = tmp_path / "here.tbaw" + existing.write_bytes(b"PK") + project_io.write_session(kept, {"dirty": False, "source_path": str(existing)}) + assert project_io.sweep_orphan_dirs() == [orphan] + assert os.path.isdir(kept) + + +@pytest.mark.parametrize("source", ["moved.tbaw", "C:moved.tbaw", "", 7]) +def test_sweep_leaves_a_dir_whose_session_source_is_not_an_absolute_path(isolated_dirs, source): + """A session the app wrote always has an absolute `source_path`; anything + else is corrupt and must not drive a delete.""" + odd, _ = project_io.create_project_dir("odd") + project_io.write_session(odd, {"dirty": False, "source_path": source}) + assert project_io.sweep_orphan_dirs() == [] + assert os.path.isdir(odd) + + +def test_second_open_of_a_locked_project_is_refused_and_the_lock_clears(isolated_dirs): + project_dir, _ = project_io.create_project_dir() + holder = project_io.ProjectLock(project_dir).acquire() + assert project_io.is_locked(project_dir) + with pytest.raises(project_io.ProjectLockedError): + project_io.ProjectLock(project_dir).acquire() + holder.release() + assert not project_io.is_locked(project_dir) + project_io.ProjectLock(project_dir).acquire().release() + + +def test_recovery_wipe_succeeds_with_the_lock_held(isolated_dirs): + project_dir, _ = project_io.create_project_dir() + holder = project_io.ProjectLock(project_dir).acquire() + try: + open(os.path.join(project_dir, "document.json"), "w").write("{}") + os.makedirs(os.path.join(project_dir, "fx")) + open(os.path.join(project_dir, "fx", "a.json"), "w").write("{}") + project_io.wipe_project_dir(project_dir) + assert os.listdir(project_dir) == ["lock"] + assert holder.held + finally: + holder.release() + + +def test_choose_project_dir_keys_on_id_and_suffixes_a_clean_dir_of_another_path(isolated_dirs, tmp_path): + pid = "feedfacefeedface" + first = os.path.join(project_io.projects_root(), pid) + assert project_io.choose_project_dir(pid, str(tmp_path / "a.tbaw")) == first + os.makedirs(first) + project_io.write_session(first, {"dirty": False, "source_path": str(tmp_path / "a.tbaw")}) + # Same file, moved through the file manager: still this dir (dirty or not). + project_io.write_session(first, {"dirty": True, "source_path": str(tmp_path / "old-name.tbaw")}) + assert project_io.choose_project_dir(pid, str(tmp_path / "renamed.tbaw")) == first + # A clean dir belonging to a Save As sibling gets out of the way. + project_io.write_session(first, {"dirty": False, "source_path": str(tmp_path / "a.tbaw")}) + assert project_io.choose_project_dir(pid, str(tmp_path / "copy.tbaw")) == first + "-2" + + +def test_project_summary_reads_only_the_manifest_and_still_summarises_json(tmp_path, isolated_dirs, monkeypatch): + project_dir, project_id = project_io.create_project_dir() + doc, _ = _document_with_audio(project_dir) + path = str(tmp_path / "s.tbaw") + project_io.save_project(doc, path, {}, project_dir, project_id) + + reads = [] + real_read = zipfile.ZipFile.read + monkeypatch.setattr(zipfile.ZipFile, "read", lambda self, name, *a: (reads.append(name), real_read(self, name, *a))[1]) + summary = project_io.project_summary(path) + assert reads == ["manifest.json"] + assert summary["clips"] == 1 and summary["characters"] == 1 and summary["duration_s"] == 1.5 + assert summary["path"] == os.path.abspath(path) + + legacy = str(tmp_path / "s.json") + with open(legacy, "w", encoding="utf-8") as f: + json.dump({"runs": [], "clips": [{}, {}], "characters": [{}, {}, {}]}, f) + legacy_summary = project_io.project_summary(legacy) + assert legacy_summary["clips"] == 2 and legacy_summary["characters"] == 3 + assert project_io.project_summary(str(tmp_path / "ghost.tbaw")) is None + bad = tmp_path / "bad.tbaw" + bad.write_bytes(b"not a zip") + assert project_io.project_summary(str(bad)) is None + + +def test_fourth_backend_engine_version_reaches_keys_and_manifest(tmp_path, isolated_dirs): + from kokoro_gui.engine.caching import segment_key + from kokoro_gui.engines.base import BackendHooksMixin, EngineCapabilities + + class FourthBackend(BackendHooksMixin): + id = "fourth" + display_name = "Fourth" + capabilities = EngineCapabilities() + engine = None + + def engine_version(self): + return "fourth-9.9" + + def get_voices(self, lang_code=None): + return [] + + backend = FourthBackend() + config = {"voice": "v", "speed": 1.0, "lang_code": "a", "engine_id": "fourth"} + assert segment_key("hi", config, backend) != segment_key("hi", {**config, "engine_id": "kokoro"}, backend) + + character = Character.from_preset_dict("F", {"voice": "v"}, backend_id="fourth") + doc = Document(runs=[], clips=[], characters=[character]) + project_dir, project_id = project_io.create_project_dir() + path = str(tmp_path / "f.tbaw") + project_io.save_project(doc, path, {}, project_dir, project_id, backend_for=lambda _id: backend) + with zipfile.ZipFile(path) as zf: + assert json.loads(zf.read("manifest.json"))["engines"] == {"fourth": {"version": "fourth-9.9", "meta": {}}} + + +def test_stats_duration_is_the_sum_of_segment_durations(): + clip = Clip(segments=[Segment(0, "a", "k", None, 1.25), Segment(1, "b", "k", None, 0.5)]) + doc = Document(runs=[], clips=[clip, Clip()], characters=[Character.from_preset_dict("A", {})]) + assert project_io.project_stats(doc) == {"clips": 2, "characters": 1, "duration_s": 1.75} + + +@pytest.mark.slow +def test_entry_over_4gb_round_trips(tmp_path, isolated_dirs): + """Zip64 is on by default; a book-length bundle crosses 4 GB.""" + project_dir, project_id = project_io.create_project_dir() + generated = os.path.join(project_dir, "audio", "generated") + os.makedirs(generated, exist_ok=True) + big = os.path.join(generated, "big_0.wav") + with open(big, "wb") as f: + f.truncate(4 * 1024 ** 3 + 1024) + clip = Clip(segments=[Segment(0, "x", "big", big, 1.0)]) + doc = Document(runs=[Run("x", clip.id, "generated")], clips=[clip]) + path = str(tmp_path / "big.tbaw") + project_io.save_project(doc, path, {}, project_dir, project_id) + info = project_io.inspect_bundle(path) + assert info.audio_bytes == 4 * 1024 ** 3 + 1024 + + +# -- app-level ---------------------------------------------------------------------------- + + +def test_launch_starts_untitled_in_a_locked_project_dir(qt_app): + assert qt_app.project_path is None + assert qt_app.settings.get("last_project") is None + assert os.path.isfile(os.path.join(qt_app.project_dir, "document.json")) + assert project_io.is_locked(qt_app.project_dir) + assert qt_app.project_dir.startswith(project_io.projects_root()) + assert qt_app.windowTitle() == "Untitled - KokoroGUI" + + +def test_save_as_then_open_restores_text_and_clips(qt_app, tmp_path): + _type(qt_app.editor, "hello world") + alice = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(0, 5, alice.id) + target = str(tmp_path / "story") + + _save_as(qt_app, target) + + assert qt_app.project_path.endswith("story.tbaw") + assert os.path.exists(qt_app.project_path) + assert qt_app.windowTitle().startswith("story") + assert qt_app.settings["recent_projects"][0] == qt_app.project_path + assert not qt_app.is_project_dirty() + + story_dir = qt_app.project_dir + qt_app.new_project() + assert qt_app.document.text == "" + assert qt_app.editor.toPlainText() == "" + assert qt_app.project_path is None + assert qt_app.project_dir != story_dir + + _open(qt_app, target + ".tbaw") + assert qt_app.document.text == "hello world" + assert qt_app.editor.toPlainText() == "hello world" + assert qt_app.document.clip_covering(2).id == clip.id + assert qt_app.project_dir == story_dir # keyed by project_id + assert not qt_app.is_project_dirty() + + +def test_save_as_json_path_writes_a_tbaw(qt_app, tmp_path): + _save_as(qt_app, str(tmp_path / "legacy.json")) + assert qt_app.project_path.endswith("legacy.tbaw") + assert zipfile.is_zipfile(qt_app.project_path) + + +def test_autosave_writes_the_project_dir_never_the_zip_and_dirty_is_by_digest(qt_app, tmp_path): + path = _save_as(qt_app, str(tmp_path / "auto")) + stamp = (os.path.getsize(path), os.path.getmtime(path)) + assert qt_app.is_project_dirty() is False + + _type(qt_app.editor, "typed later") + qt_app.save_settings() + + with open(os.path.join(qt_app.project_dir, "document.json"), encoding="utf-8") as f: + data = json.load(f) + assert "".join(r["text"] for r in data["runs"]) == "typed later" + assert (os.path.getsize(path), os.path.getmtime(path)) == stamp + assert qt_app.is_project_dirty() is True + assert project_io.read_session(qt_app.project_dir)["dirty"] is True + assert qt_app.windowTitle() == "auto* - KokoroGUI" + + qt_app.save_project() + qt_app.wait_for_project_io() + assert qt_app.is_project_dirty() is False + assert qt_app.windowTitle() == "auto - KokoroGUI" + assert project_io.load_project(path).document.text == "typed later" + + +def test_open_followed_by_no_edit_leaves_dirty_false(qt_app, tmp_path): + _type(qt_app.editor, "some text") + path = _save_as(qt_app, str(tmp_path / "quiet")) + qt_app.new_project() + _open(qt_app, path) + qt_app.save_settings() # the trailing autosave after _switch_document + assert qt_app.is_project_dirty() is False + + +def test_generated_clip_lands_in_the_project_dir_named_by_key_and_is_bundled(qt_app, tmp_path): + clip = _generated_clip(qt_app) + config = qt_app._assemble_clip_config(clip) + assert config["out_dir"] == os.path.join(qt_app.project_dir, "audio", "generated") + assert config["segment_naming"] == "cache_key" + assert config["project_dir"] == qt_app.project_dir + assert qt_app.document.dirty_clips() == [] + + path = _save_as(qt_app, str(tmp_path / "gen")) + with zipfile.ZipFile(path) as zf: + audio = [n for n in zf.namelist() if n.startswith("audio/generated/")] + assert audio == [f"audio/generated/{clip.segments[0].cache_key}_0.wav"] + assert zf.getinfo(audio[0]).compress_type == zipfile.ZIP_STORED + + +def test_save_as_keeps_project_dir_and_audio_paths(qt_app, tmp_path): + clip = _generated_clip(qt_app) + before = clip.segments[0].audio_path + project_dir = qt_app.project_dir + _save_as(qt_app, str(tmp_path / "first")) + _save_as(qt_app, str(tmp_path / "second")) + assert qt_app.project_dir == project_dir + assert clip.segments[0].audio_path == before + assert project_io.read_session(project_dir)["source_path"].endswith("second.tbaw") + assert os.path.isfile(str(tmp_path / "first.tbaw")) and os.path.isfile(str(tmp_path / "second.tbaw")) + + +def test_regenerate_save_undo_leaves_clip_dirty_rather_than_silent(qt_app, tmp_path): + from kokoro_gui.daw.undo import TextEditCommand + + clip = _generated_clip(qt_app, text="hello world") + old_segment_path = clip.segments[0].audio_path + # A text edit snapshots the clip (segments included) for undo. + qt_app.document.undo_stack.push(TextEditCommand(6, 5, 5, "hello WORLD")) + assert qt_app.document.clip_text(clip) == "hello WORLD" + # "Regenerate" the edited clip: a new file, the old one now an orphan. + text = qt_app.document.clip_text(clip) + key = qt_app.document.segment_key_fn(text, clip) + new_path = os.path.join(os.path.dirname(old_segment_path), f"{key}_0.wav") + with open(new_path, "wb") as f: + f.write(open(old_segment_path, "rb").read()) + clip.segments = build_segments_from_results(key, [{"text": text, "path": new_path, "duration": 0.1, + "cache_key": key}]) + _save_as(qt_app, str(tmp_path / "undo")) + assert os.path.isfile(old_segment_path) # Save deletes nothing (TB11) + + qt_app.document.undo_stack.undo() + restored = qt_app.document.get_clip(clip.id) + assert restored.segments[0].audio_path == old_segment_path + assert qt_app.document.dirty_clips() == [] # the file is still there + os.remove(old_segment_path) # what a close-time GC would have done + assert [c.id for c in qt_app.document.dirty_clips()] == [clip.id] + + +def test_clean_close_gcs_orphans_keeps_last_project_dir_and_deletes_others(qt_app, tmp_path): + clip = _generated_clip(qt_app) + orphan = os.path.join(qt_app.project_dir, "audio", "generated", "orphan_0.wav") + open(orphan, "wb").write(b"RIFF") + other_dir, _ = project_io.create_project_dir("other") + path = _save_as(qt_app, str(tmp_path / "keepme")) + project_dir = qt_app.project_dir + + qt_app.close() + + assert os.path.isdir(project_dir) + assert os.path.isfile(clip.segments[0].audio_path) + assert not os.path.exists(orphan) + assert not os.path.exists(other_dir) + assert not project_io.is_locked(project_dir) + assert qt_app.settings["last_project"] == path + + +def test_discard_on_close_deletes_the_project_dir(qt_app, tmp_path, monkeypatch): + _save_as(qt_app, str(tmp_path / "d")) + project_dir = qt_app.project_dir + _type(qt_app.editor, "unsaved") + monkeypatch.setattr(type(qt_app), "_ask_close_choice", lambda self: "discard") + qt_app.close() + assert not os.path.exists(project_dir) + + +def test_cancel_on_close_keeps_the_window_open(qt_app, tmp_path, monkeypatch): + _save_as(qt_app, str(tmp_path / "c")) + _type(qt_app.editor, "unsaved") + monkeypatch.setattr(type(qt_app), "_ask_close_choice", lambda self: "cancel") + qt_app.close() + assert qt_app.project_dir is not None and project_io.is_locked(qt_app.project_dir) + + +def test_save_on_close_writes_the_bundle_then_closes(qt_app, tmp_path, monkeypatch): + path = _save_as(qt_app, str(tmp_path / "s")) + _type(qt_app.editor, "kept") + monkeypatch.setattr(type(qt_app), "_ask_close_choice", lambda self: "save") + qt_app.close() + qt_app.wait_for_project_io() + assert project_io.load_project(path).document.text == "kept" + assert qt_app.project_dir is None + + +def test_recover_prompt_is_driven_by_dirty_and_found_by_id_after_a_rename(qt_app, tmp_path, monkeypatch): + _type(qt_app.editor, "saved text") + path = _save_as(qt_app, str(tmp_path / "crash")) + project_dir = qt_app.project_dir + _type(qt_app.editor, "saved text plus unsaved") + qt_app.save_settings() + assert project_io.read_session(project_dir)["dirty"] is True + + # Simulate a crash: the lock goes away, the dir stays dirty, the file is renamed. + _simulate_crash(qt_app) + renamed = str(tmp_path / "renamed.tbaw") + os.rename(path, renamed) + + asked = [] + monkeypatch.setattr(type(qt_app), "_ask_recover_choice", + lambda self, session, info, pdir: (asked.append((session, pdir)), "keep")[1]) + _open(qt_app, renamed) + assert asked and asked[0][1] == project_dir + assert qt_app.project_dir == project_dir + assert qt_app.document.text == "saved text plus unsaved" + assert qt_app.is_project_dirty() is True + + +def test_recover_take_file_wipes_and_extracts_fresh(qt_app, tmp_path, monkeypatch): + _type(qt_app.editor, "saved text") + path = _save_as(qt_app, str(tmp_path / "crash2")) + project_dir = qt_app.project_dir + _type(qt_app.editor, "unsaved edits") + qt_app.save_settings() + _simulate_crash(qt_app) + + monkeypatch.setattr(type(qt_app), "_ask_recover_choice", lambda self, session, info, pdir: "take") + _open(qt_app, path) + assert qt_app.project_dir == project_dir + assert qt_app.document.text == "saved text" + assert qt_app.is_project_dirty() is False + + +def test_open_with_audio_runs_behind_is_busy_and_refuses_a_generate(qt_app, tmp_path, monkeypatch): + _generated_clip(qt_app) + path = _save_as(qt_app, str(tmp_path / "busy")) + qt_app.new_project() + + import threading + + gate = threading.Event() + real_extract = project_io.extract_audio + + def slow_extract(*args, **kwargs): + gate.wait(5) + return real_extract(*args, **kwargs) + + monkeypatch.setattr(project_io, "extract_audio", slow_extract) + qt_app.open_project(path) + assert qt_app.is_busy() + assert qt_app.editor.isReadOnly() + qt_app.on_generate_clicked() + assert not qt_app.engine.generate_dirty_clips.called + gate.set() + qt_app.wait_for_project_io() + assert not qt_app.is_busy() + assert not qt_app.editor.isReadOnly() + assert qt_app.document.clips and qt_app.document.dirty_clips() == [] + + +def test_open_of_a_bundle_with_another_engine_version_is_clean_with_a_status_line(qt_app, tmp_path, monkeypatch): + clip = _generated_clip(qt_app) + path = _save_as(qt_app, str(tmp_path / "ver")) + with zipfile.ZipFile(path) as zf: + manifest = json.loads(zf.read("manifest.json")) + assert manifest["engines"]["kokoro"]["version"] == qt_app.backend.engine_version() + qt_app.new_project() + + monkeypatch.setattr(type(qt_app.backend), "engine_version", lambda self: "99.0-other") + qt_app._install_segment_key_fn() + _open(qt_app, path) + assert qt_app.document.get_clip(clip.id) is not None + assert qt_app.document.dirty_clips() == [] + assert "99.0-other" in qt_app.transport_dock.status_text() + + +def test_json_project_migrates_to_tbaw_adopting_matching_segments(qt_app, tmp_path): + from kokoro_gui.engine.caching import effective_speed + + # A 4.0-preview project: one clean clip (legacy key, file present), one whose + # key already disagreed, one whose file is gone. + audio_dir = tmp_path / "audio_output" + audio_dir.mkdir() + character = Character.from_preset_dict("Old", {"voice": "af_heart"}) + doc = Document(runs=[], clips=[], characters=[character]) + texts = ["clean clip", "stale clip", "gone clip"] + doc.text = "\n".join(texts) + clips = [] + pos = 0 + for text in texts: + clips.append(doc.assign_character_to_range(pos, pos + len(text), character.id)) + pos += len(text) + 1 + qt_app.document = doc # so the app's generation config sees these characters + qt_app._install_segment_key_fn() + for clip, text in zip(clips, texts): + config = qt_app._assemble_generation_config(clip) + legacy = project_io.legacy_segment_key(text, config) + src = audio_dir / f"{text.replace(' ', '_')}.wav" + src.write_bytes(b"RIFF" + b"\0" * 40) + key = legacy if text != "stale clip" else "stale-key" + clip.segments = [Segment(0, text, key, str(src), 1.0)] + os.remove(audio_dir / "gone_clip.wav") + legacy_path = str(tmp_path / "old.json") + project_io.save_json_project(doc, legacy_path, {"export": {"format": "ogg"}}) + qt_app.new_project() + + _open(qt_app, legacy_path) + + assert qt_app.project_path == str(tmp_path / "old.tbaw") + assert os.path.isfile(legacy_path) # left where it was + assert zipfile.is_zipfile(qt_app.project_path) + assert qt_app.project_settings == {"export": {"format": "ogg"}} + assert qt_app.settings["last_project"] == qt_app.project_path + assert not any(p.endswith("old.json") for p in qt_app.settings["recent_projects"]) + by_text = {qt_app.document.clip_text(c): c for c in qt_app.document.clips} + clean = by_text["clean clip"] + assert clean.segments[0].audio_path.startswith(qt_app.project_dir) + assert os.path.isfile(clean.segments[0].audio_path) + assert clean.segments[0].cache_key == qt_app.document.segment_key_fn("clean clip", clean) + assert by_text["stale clip"].segments[0].audio_path is None + assert by_text["gone clip"].segments[0].audio_path is None + dirty_ids = {c.id for c in qt_app.document.dirty_clips()} + assert dirty_ids == {by_text["stale clip"].id, by_text["gone clip"].id} + + +def test_json_project_in_unwritable_dir_falls_through_to_save_as(qt_app, tmp_path, monkeypatch): + doc = Document.from_plain_text("ro", characters=[Character.from_preset_dict("A", {})]) + legacy_path = str(tmp_path / "ro.json") + project_io.save_json_project(doc, legacy_path, {}) + monkeypatch.setattr(os, "access", lambda p, mode: False) + elsewhere = str(tmp_path / "elsewhere" / "moved") + monkeypatch.setattr(type(qt_app), "_save_as_path_dialog", lambda self: project_io.bundle_path_for(elsewhere)) + _open(qt_app, legacy_path) + assert qt_app.project_path == elsewhere + ".tbaw" + assert os.path.isfile(qt_app.project_path) + + +def test_project_local_asset_shadows_the_global_one(qt_app, tmp_path, monkeypatch): + import kokoro_engine + + global_dir = tmp_path / "custom_voices" + global_dir.mkdir(exist_ok=True) + monkeypatch.setattr(kokoro_engine, "CUSTOM_VOICES_DIR", str(global_dir)) + (global_dir / "Mix.pt").write_bytes(b"global") + local_dir = os.path.join(qt_app.project_dir, "engines", "kokoro", "voices") + os.makedirs(local_dir) + with open(os.path.join(local_dir, "Mix.pt"), "wb") as f: + f.write(b"project-local") + + assert qt_app.backend.resolve_voice_file("Mix", qt_app.project_dir) == os.path.join(local_dir, "Mix.pt") + assert [v.id for v in qt_app.backend.get_voices()] == ["Mix"] + assert qt_app.backend.project_dir == qt_app.project_dir + + # And a Save bundles the project copy. + qt_app.document.characters[0].preset_data["voice"] = "Mix" + path = _save_as(qt_app, str(tmp_path / "shadow")) + with zipfile.ZipFile(path) as zf: + assert zf.read("engines/kokoro/voices/Mix.pt") == b"project-local" + + +def test_bundle_toggles_live_in_the_export_dialog_and_feed_the_clip_config(qt_app, tmp_path): + from kokoro_gui.qt.docks.export_dialog import ExportDialog, run_export + + dialog = ExportDialog(qt_app) + assert dialog.bundle_audio_check.isChecked() is True + assert dialog.bundle_format_combo.currentText() == "wav" + dialog.bundle_audio_check.setChecked(False) + dialog.bundle_format_combo.setCurrentText("flac") + run_export(qt_app, dialog.values(), bundle=dialog.bundle_values()) # no clips: nothing scheduled + + assert qt_app.project_settings["bundle"] == { + "include_generated_audio": False, "include_imported_audio": True, "audio_format": "flac", + } + clip = _generated_clip(qt_app) + assert qt_app._assemble_clip_config(clip)["format"] == "flac" + path = _save_as(qt_app, str(tmp_path / "toggles")) + with zipfile.ZipFile(path) as zf: + manifest = json.loads(zf.read("manifest.json")) + assert manifest["audio"]["format"] == "flac" + assert manifest["includes"]["generated_audio"] is False + assert not any(n.startswith("audio/") for n in zf.namelist()) + assert project_io.load_project(path).project_settings["bundle"]["audio_format"] == "flac" + + +def test_recent_menu_lists_projects_and_opens_them(qt_app, tmp_path): + _save_as(qt_app, str(tmp_path / "one")) + _save_as(qt_app, str(tmp_path / "two")) + + texts = [a.text() for a in qt_app.recent_menu.actions()] + assert texts[:2] == ["two", "one"] + + next(a for a in qt_app.recent_menu.actions() if a.text() == "one").trigger() + qt_app.wait_for_project_io() + assert qt_app.project_path.endswith("one.tbaw") + + +def test_new_project_inherits_characters_and_clears_selection(qt_app): + bob = Character.from_preset_dict("Bob", {}) + qt_app.document.characters.append(bob) + _type(qt_app.editor, "hello") + qt_app.document.assign_character_to_range(0, 5, bob.id) + qt_app.editor.rehighlight() + qt_app.selection.select_clip(qt_app.document.clips[0].id) + + qt_app.new_project() + + assert [c.name for c in qt_app.document.characters] == ["Default", "Bob"] + assert qt_app.document.clips == [] + assert qt_app.selection.kind == "none" + assert qt_app.transcript_dock.character_combo.findData(qt_app.document.characters[1].id) >= 0 + + +def test_open_missing_project_warns_and_forgets_it(qt_app, tmp_path, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + warned = [] + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: warned.append(a))) + ghost = str(tmp_path / "ghost.tbaw") + project_io.remember_recent(qt_app.settings, ghost) + qt_app._rebuild_recent_menu() + + qt_app.open_project(ghost) + + assert warned + assert not any(p.endswith("ghost.tbaw") for p in qt_app.settings["recent_projects"]) + + +def test_import_text_add_inserts_at_caret_on_native_undo(qt_app, tmp_path): + _type(qt_app.editor, "start ") + qt_app.engine.extract_text_from_file.return_value = "imported words" + src = tmp_path / "in.txt" + src.write_text("imported words", encoding="utf-8") + + qt_app.import_text(str(src), target="add") + + assert qt_app.document.text == "start imported words" + assert qt_app.editor.toPlainText() == "start imported words" + qt_app.undo() + assert qt_app.document.text == "start " + + +def test_import_text_new_starts_a_fresh_project_with_the_text(qt_app, tmp_path): + _type(qt_app.editor, "old") + qt_app.engine.extract_text_from_file.return_value = "chapter one" + src = tmp_path / "in.txt" + src.write_text("chapter one", encoding="utf-8") + + qt_app.import_text(str(src), target="new") + + assert qt_app.document.text == "chapter one" + assert qt_app.project_path is None + + +@pytest.mark.skipif(sys.platform != "win32", reason="the replace retry is a Windows behaviour") +def test_replace_retries_on_permission_error(tmp_path, monkeypatch): + calls = [] + real_replace = os.replace + + def flaky(src, dst): + calls.append(1) + if len(calls) < 3: + raise PermissionError("held by a scanner") + return real_replace(src, dst) + + monkeypatch.setattr(os, "replace", flaky) + monkeypatch.setattr(project_io.time, "sleep", lambda _s: None) + src = tmp_path / "a.tmp" + src.write_bytes(b"x") + project_io._replace_with_retries(str(src), str(tmp_path / "a")) + assert len(calls) == 3 and (tmp_path / "a").exists() diff --git a/tests/gui_qt/test_qt_config_assembly.py b/tests/gui_qt/test_qt_config_assembly.py new file mode 100644 index 0000000..06a96b7 --- /dev/null +++ b/tests/gui_qt/test_qt_config_assembly.py @@ -0,0 +1,140 @@ +"""Config-dict assembly contract for the Qt frontend's `_assemble_config()`, +checked against the mirrored constants in kokoro_gui/qt/spec.py.""" +from kokoro_gui.qt import spec + + +def test_assembled_config_matches_mirrored_spec_keys(qt_app): + config = qt_app._assemble_config() + expected = set(spec.GENERATION_BASE_KEYS) | set(spec.FX_PRESET_KEYS) + assert set(config.keys()) == expected + + +def test_assembled_config_omits_fx_keys_when_apply_fx_off(qt_app): + qt_app.settings_dock.apply_fx_check.setChecked(False) + config = qt_app._assemble_config() + assert set(config.keys()) == set(spec.GENERATION_BASE_KEYS) + assert "reverb_enabled" not in config + assert "gain_db" not in config + + +def test_assembled_config_time_id_is_timecode(qt_app): + import re + config = qt_app._assemble_config() + assert re.match(r"^\d{14}$", config["time_id"]) + + +def test_generation_dock_state_covers_base_keys_minus_settings_owned(qt_app): + """Everything _assemble_config adds on top of the Generation dock's own + get_state() (engine_id/time_id/lexicon) is intentionally settings-owned, + not dock-owned - see app.py's _assemble_config.""" + state = qt_app.settings_dock.get_state() + settings_owned = {"engine_id", "time_id", "lexicon"} + # Output/format/subtitles/keep-segments live in the Export dialog now + # (kokoro_gui/qt/docks/export_dialog.py), not the Settings tab. + export_owned = {"filename", "out_dir", "separate", "combine", "export_subtitles"} + assert set(state.keys()) | settings_owned | export_owned == set(spec.GENERATION_BASE_KEYS) + + +def test_fx_dock_state_covers_all_fx_preset_keys(qt_app): + state = qt_app.fx_dock.get_state() + assert set(state.keys()) == set(spec.FX_PRESET_KEYS) + + +# --- generation config and the segment key closure (Claude/old/PLAN_tbaw_bundle.md 2.3) -- + +def _clip_for(qt_app, text="hello world", preset=None): + from kokoro_gui.daw.models import Character + + qt_app.document.text = text + character = Character.from_preset_dict("NoLang", preset or {"voice": "af_sarah"}) + qt_app.document.characters.append(character) + return qt_app.document.assign_character_to_range(0, len(text), character.id) + + +def test_dirty_check_and_generate_hash_the_same_config_without_lang_code_in_the_preset(qt_app): + """The character's preset carries no `lang_code`, so the app default + fills it in for both paths: `_assemble_generation_config` decides what + the key hashes, `_assemble_clip_config` is built on it.""" + from kokoro_gui.engine.caching import segment_key + + clip = _clip_for(qt_app) + text = qt_app.document.clip_text(clip) + generation = qt_app._assemble_generation_config(clip) + full = qt_app._assemble_clip_config(clip) + + assert "lang_code" in generation and generation["voice"] == "af_sarah" + assert {k: full[k] for k in generation} == generation + assert qt_app.document.segment_key_fn(text, clip) == segment_key(text, full, qt_app.backend) + + +def test_generation_config_reads_take_off_the_clip_and_carries_project_dir(qt_app): + clip = _clip_for(qt_app) + assert qt_app._assemble_generation_config(clip)["take"] == 0 + clip.overrides["take"] = 3 + config = qt_app._assemble_generation_config(clip) + assert config["take"] == 3 + assert "project_dir" in config + # `take` never comes through the preset whitelist. + assert "take" not in qt_app.document.effective_config_for_clip(clip) + + +def test_segment_key_fn_is_memoized_and_notices_a_rewritten_voice_file(qt_app, tmp_path, monkeypatch): + import os + + import kokoro_engine + from kokoro_gui.engine import caching + + voices = tmp_path / "custom_voices" + voices.mkdir(exist_ok=True) + monkeypatch.setattr(kokoro_engine, "CUSTOM_VOICES_DIR", str(voices)) + mix = voices / "Mix.pt" + mix.write_bytes(b"v1") + clip = _clip_for(qt_app, preset={"voice": "Mix"}) + text = qt_app.document.clip_text(clip) + + calls = [] + real = caching.segment_key + monkeypatch.setattr(caching, "segment_key", lambda *a, **k: (calls.append(1), real(*a, **k))[1]) + + first = qt_app.document.segment_key_fn(text, clip) + again = qt_app.document.segment_key_fn(text, clip) + assert first == again and len(calls) == 1 + + mix.write_bytes(b"v2 longer") + future = os.path.getmtime(mix) + 5 + os.utime(mix, (future, future)) + assert qt_app.document.segment_key_fn(text, clip) != first + assert len(calls) == 2 + + +def test_dirty_clips_on_many_clips_reads_no_files(qt_app, tmp_path, monkeypatch): + """A rehighlight of a book: stats only, no `open()` on any segment, + voice or transcript file.""" + import builtins + import os + + from kokoro_gui.daw.dirty import build_segments_from_results + + text = " ".join(f"line{i}." for i in range(200)) + qt_app.document.text = text + character = qt_app.document.characters[0] + clips = [] + pos = 0 + for i in range(200): + end = text.index(" ", pos) if i < 199 else len(text) + clips.append(qt_app.document.assign_character_to_range(pos, end, character.id)) + pos = end + 1 + for i, clip in enumerate(clips): + path = tmp_path / f"seg{i}.wav" + path.write_bytes(b"RIFF") + key = qt_app.document.segment_key_fn(qt_app.document.clip_text(clip), clip) + clip.segments = build_segments_from_results(key, [{ + "text": qt_app.document.clip_text(clip), "path": str(path), "duration": 1.0, "cache_key": key, + }]) + + opened = [] + real_open = builtins.open + monkeypatch.setattr(builtins, "open", lambda *a, **k: (opened.append(a[0]), real_open(*a, **k))[1]) + assert qt_app.document.dirty_clips() == [] + assert opened == [] + assert os.path.isfile(str(tmp_path / "seg0.wav")) diff --git a/tests/gui_qt/test_qt_document_wiring.py b/tests/gui_qt/test_qt_document_wiring.py new file mode 100644 index 0000000..dc374f4 --- /dev/null +++ b/tests/gui_qt/test_qt_document_wiring.py @@ -0,0 +1,69 @@ +"""Tests confirming kokoro_gui.daw.models.Document is actually wired into a +running QtTTSApp (Workstream 2's document_state.load_or_create_document call +in app.py's __init__, and the autosave -> project dir -> resume round trip).""" +import os + +import kokoro_gui.qt.app as qt_app_module +from kokoro_gui.daw.serialization import load_document + + +def test_fresh_app_has_a_populated_document(qt_app): + assert qt_app.document is not None + # A brand-new tmp_path has no presets/document.json, so migration seeds + # exactly one "Default" character (see migration.py). + assert len(qt_app.document.characters) == 1 + + +def test_save_settings_writes_document_json_into_the_project_dir(qt_app): + """Autosave writes the live project dir, never a file at DOCUMENT_FILE + (that path is only read, for the one-time migration to .tbaw).""" + qt_app.document.text = "hello world" + qt_app.save_settings() + + loaded = load_document(os.path.join(qt_app.project_dir, "document.json")) + assert loaded is not None + assert loaded.text == "hello world" + assert not os.path.exists(qt_app_module.DOCUMENT_FILE) + + +def test_document_persists_across_app_construction(qt_app, tmp_path): + qt_app.document.text = "hello world" + clip = qt_app.document.assign_character_to_range(0, 5, qt_app.document.characters[0].id) + qt_app.save_project_as(str(tmp_path / "persist")) + qt_app.wait_for_project_io() + qt_app.close() # releases the lock, keeps the last project's dir + + # A second QtTTSApp against the same (monkeypatched) config resumes the + # last project from its kept dir (TB13), not a re-migration. + second_app = qt_app_module.QtTTSApp() + try: + second_app.wait_for_project_io() + assert second_app.project_path == qt_app.project_path + assert second_app.document.text == "hello world" + assert second_app.document.get_clip(clip.id) is not None + finally: + second_app.close() + + +def test_legacy_document_json_next_to_the_config_migrates_on_launch(qt_app, tmp_path): + """The 4.0-preview implicit `document.json` becomes `document.tbaw` on the + first launch that finds it.""" + from kokoro_gui.daw.models import Character, Document + from kokoro_gui.qt import project as project_io + + qt_app.close() + doc = Document.from_plain_text("legacy words", characters=[Character.from_preset_dict("L", {})]) + project_io.save_json_project(doc, qt_app_module.DOCUMENT_FILE, {}) + qt_app.settings["last_project"] = None + import kokoro_gui.qt.settings as qt_settings + + qt_settings.save_settings(qt_app_module.CONFIG_FILE, qt_app.settings) + + second_app = qt_app_module.QtTTSApp() + try: + second_app.wait_for_project_io() + assert second_app.document.text == "legacy words" + assert second_app.project_path == project_io.bundle_path_for(os.path.abspath(qt_app_module.DOCUMENT_FILE)) + assert os.path.isfile(second_app.project_path) + finally: + second_app.close() diff --git a/tests/gui_qt/test_qt_engine_backend.py b/tests/gui_qt/test_qt_engine_backend.py new file mode 100644 index 0000000..e0658d7 --- /dev/null +++ b/tests/gui_qt/test_qt_engine_backend.py @@ -0,0 +1,60 @@ +"""Engine-picker switch behavior, including re-rendering the Generation +dock's schema-driven fields for the newly-active backend. See app.py's +`switch_engine` docstring.""" +from kokoro_gui.engines import registry as engine_registry + + +def test_dummy_and_kokoro_both_registered(): + assert {"kokoro", "dummy"} <= set(engine_registry.list_engines()) + + +def test_kokoro_backend_shows_mixing_dock(qt_app): + assert qt_app.backend.id == "kokoro" + assert qt_app.mixing_dock is not None + + +def test_switch_to_dummy_hides_mixing_dock(qt_app): + qt_app.switch_engine("dummy") + assert qt_app.backend.id == "dummy" + assert qt_app.mixing_dock is None + + +def test_switch_back_to_kokoro_shows_mixing_dock_again(qt_app): + qt_app.switch_engine("dummy") + qt_app.switch_engine("kokoro") + assert qt_app.backend.id == "kokoro" + assert qt_app.mixing_dock is not None + + +def test_switch_engine_rebuilds_schema_form_for_new_backend(qt_app): + """The Generation dock's schema-driven fields must reflect the + newly-active backend's schema, not stay frozen at whatever the first + backend built.""" + original_form = qt_app.settings_dock.schema_form + qt_app.switch_engine("dummy") + assert qt_app.settings_dock.schema_form is not original_form + + dummy_schema_keys = {f.key for f in qt_app.backend.get_config_schema()} + assert "lexicon" not in dummy_schema_keys # dummy backend has no lexicon field + rendered_keys = set(qt_app.settings_dock.schema_form.values().keys()) + # "pitch" is skip_keyed too - SettingsDock renders it via its own + # hand-built pitch_spin (Audio Control), not the schema form, to avoid + # two independent widgets fighting over the same override slot. + assert rendered_keys == dummy_schema_keys - {"lexicon", "pitch"} + + +def test_switch_engine_refused_while_job_running(qt_app): + qt_app.transport_dock.set_busy(True) # simulate a job in flight + original_backend_id = qt_app.backend.id + qt_app.switch_engine("dummy") + assert qt_app.backend.id == original_backend_id + + +def test_engine_menu_lists_all_registered_engines_with_active_checked(qt_app): + items = {eid: a.text() for eid, a in qt_app.engine_actions.items()} + expected = {eid: engine_registry.get_display_name(eid) for eid in engine_registry.list_engines()} + assert items == expected + assert qt_app.engine_actions[qt_app.backend.id].isChecked() + qt_app.switch_engine("dummy") + assert qt_app.engine_actions["dummy"].isChecked() + assert not qt_app.engine_actions["kokoro"].isChecked() diff --git a/tests/gui_qt/test_qt_lexicon.py b/tests/gui_qt/test_qt_lexicon.py new file mode 100644 index 0000000..0532fa0 --- /dev/null +++ b/tests/gui_qt/test_qt_lexicon.py @@ -0,0 +1,44 @@ +"""Lexicon CRUD roundtrips into settings["lexicon"] with eager save (bypasses +the debounced autosave).""" +import json + + +def test_add_rule_updates_settings_and_saves_eagerly(qt_app): + import kokoro_gui.qt.app as qt_app_module + qt_app.lexicon_dock.orig_edit.setText("API") + qt_app.lexicon_dock.replace_edit.setText("A P I") + qt_app.lexicon_dock.add_rule() + + assert qt_app.settings["lexicon"] == {"API": "A P I"} + # Eager save - config_qt.json exists immediately, no need to advance a timer. + with open(qt_app_module.CONFIG_FILE, encoding="utf-8") as f: + data = json.load(f) + assert data["lexicon"] == {"API": "A P I"} + + +def test_add_rule_rejects_empty_original(qt_app): + qt_app.lexicon_dock.orig_edit.setText("") + qt_app.lexicon_dock.replace_edit.setText("something") + qt_app.lexicon_dock.add_rule() + assert qt_app.settings.get("lexicon", {}) == {} + + +def test_delete_rule_removes_entry(qt_app): + qt_app.settings["lexicon"] = {"foo": "bar", "baz": "qux"} + qt_app.lexicon_dock.refresh_list() + qt_app.lexicon_dock.delete_rule("foo") + assert qt_app.settings["lexicon"] == {"baz": "qux"} + + +def test_add_rule_clears_input_fields(qt_app): + qt_app.lexicon_dock.orig_edit.setText("x") + qt_app.lexicon_dock.replace_edit.setText("y") + qt_app.lexicon_dock.add_rule() + assert qt_app.lexicon_dock.orig_edit.text() == "" + assert qt_app.lexicon_dock.replace_edit.text() == "" + + +def test_lexicon_feeds_into_assembled_config(qt_app): + qt_app.settings["lexicon"] = {"TTS": "Tee Tee Ess"} + config = qt_app._assemble_config() + assert config["lexicon"] == {"TTS": "Tee Tee Ess"} diff --git a/tests/gui_qt/test_qt_presets.py b/tests/gui_qt/test_qt_presets.py new file mode 100644 index 0000000..91fae14 --- /dev/null +++ b/tests/gui_qt/test_qt_presets.py @@ -0,0 +1,38 @@ +"""FX preset save/load (presets/fx/*.json). The legacy generation-preset +row left the transcript panel with the UI shell redesign (Characters +replaced it), so only FX presets have GUI save/load now.""" +import json +import os + +from PySide6.QtWidgets import QInputDialog + + +def _stub_get_text(monkeypatch, value): + monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: (value, True))) + + +def test_save_fx_preset_writes_all_43_keys(qt_app, monkeypatch): + from kokoro_gui.qt import spec + _stub_get_text(monkeypatch, "MyFX") + qt_app.fx_dock._value_widgets["gain_db"].setValue(3.0) + qt_app.fx_dock._save_preset_dialog() + + import kokoro_gui.qt.app as qt_app_module + fpath = os.path.join(qt_app_module.FX_PRESETS_DIR, "MyFX.json") + assert os.path.exists(fpath) + with open(fpath, encoding="utf-8") as f: + data = json.load(f) + assert set(data.keys()) == set(spec.FX_PRESET_KEYS) + assert data["gain_db"] == 3.0 + + +def test_load_fx_preset_applies_values_and_syncs_gen_combo(qt_app, monkeypatch): + _stub_get_text(monkeypatch, "LoudFX") + qt_app.fx_dock._value_widgets["gain_db"].setValue(9.0) + qt_app.fx_dock._save_preset_dialog() + + qt_app.fx_dock._value_widgets["gain_db"].setValue(0.0) + qt_app.fx_dock.load_preset("LoudFX") + + assert qt_app.fx_dock._value_widgets["gain_db"].value() == 9.0 + assert qt_app.settings_dock.fx_preset_combo.currentText() == "LoudFX" diff --git a/tests/gui_qt/test_qt_settings.py b/tests/gui_qt/test_qt_settings.py new file mode 100644 index 0000000..b3dd702 --- /dev/null +++ b/tests/gui_qt/test_qt_settings.py @@ -0,0 +1,88 @@ +"""config_qt.json load/save roundtrip, debounce, and dock-state persistence.""" +import json +import os + +from kokoro_gui.qt import settings as qt_settings + + +def test_save_settings_writes_config_qt_json(qt_app): + import kokoro_gui.qt.app as qt_app_module + qt_app.settings_dock.volume_spin.setValue(1.7) + qt_app.save_settings() + + assert os.path.exists(qt_app_module.CONFIG_FILE) + with open(qt_app_module.CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + assert data["volume"] == 1.7 + + +def test_export_settings_persist_in_the_project_file(qt_app, tmp_path): + import os + + from kokoro_gui.qt import project as project_io + + qt_app.project_settings["export"] = {"filename": "my_output", "format": "flac"} + qt_app.save_settings() + + with open(os.path.join(qt_app.project_dir, "project.json"), "r", encoding="utf-8") as f: + data = json.load(f) + assert data["export"]["filename"] == "my_output" + assert qt_app._assemble_config()["filename"] == "my_output" + assert qt_app._assemble_config()["format"] == "flac" + + qt_app.save_project_as(str(tmp_path / "exp")) + qt_app.wait_for_project_io() + assert project_io.load_project(qt_app.project_path).project_settings["export"]["filename"] == "my_output" + + +def test_save_settings_persists_fx_state(qt_app): + import kokoro_gui.qt.app as qt_app_module + qt_app.fx_dock._value_widgets["gain_db"].setValue(6.5) + qt_app.save_settings() + + with open(qt_app_module.CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + assert data["gain_db"] == 6.5 + + +def test_save_settings_stores_active_workspace_layout(qt_app): + qt_app.save_settings() + entry = qt_app.settings["workspaces"][qt_app.settings["active_workspace"]] + assert entry["state"] + assert entry["geometry"] + + +def test_schedule_save_debounces(qt_app, qtbot): + calls = [] + qt_app.save_settings = lambda: calls.append(1) + qt_app._save_timer.timeout.disconnect() + qt_app._save_timer.timeout.connect(qt_app.save_settings) + + qt_app.schedule_save() + qt_app.schedule_save() # restarting the timer shouldn't double-fire + qtbot.wait(1300) + assert calls == [1] + + +def test_load_settings_defaults_when_no_file(tmp_path): + cfg = str(tmp_path / "does_not_exist.json") + settings = qt_settings.load_settings(cfg) + from kokoro_gui.qt import spec + assert settings == spec.SETTINGS_DEFAULTS + + +def test_load_settings_merges_over_defaults(tmp_path): + cfg = tmp_path / "config_qt.json" + cfg.write_text(json.dumps({"voice": "af_bella"}), encoding="utf-8") + settings = qt_settings.load_settings(str(cfg)) + assert settings["voice"] == "af_bella" + assert settings["speed"] == 1.0 # untouched default survives the merge + + +def test_save_then_load_roundtrip(tmp_path): + cfg = str(tmp_path / "config_qt.json") + data = {"voice": "af_bella", "speed": 1.3} + qt_settings.save_settings(cfg, data) + loaded = qt_settings.load_settings(cfg) + assert loaded["voice"] == "af_bella" + assert loaded["speed"] == 1.3 diff --git a/tests/gui_qt/test_qt_timeline_dock.py b/tests/gui_qt/test_qt_timeline_dock.py new file mode 100644 index 0000000..39a7338 --- /dev/null +++ b/tests/gui_qt/test_qt_timeline_dock.py @@ -0,0 +1,116 @@ +"""Tests for kokoro_gui/qt/docks/timeline_dock.py's TimelineDock, wired into +the running app - qt_app fixture. Reuses test_transcript_editor.py's +editor-driving helpers to confirm the refresh mechanism end-to-end (via the +real editor, not just calling Document methods directly).""" +from PySide6.QtCore import QMimeData, Qt +from PySide6.QtGui import QTextCursor + +from kokoro_gui.qt.timeline_view import ClipBlockItem + + +def _editor(qt_app): + return qt_app.editor + + +def _set_text_via_real_edit(editor, text): + cursor = editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(text) + + +def _mime_with_character(text, character_id): + mime = QMimeData() + mime.setText(text) + mime.setData("application/x-kokorogui-character-id", character_id.encode("utf-8")) + return mime + + +def _clip_block_items(dock): + return [item for item in dock.timeline_view._scene.items() if isinstance(item, ClipBlockItem)] + + +def test_timeline_dock_constructed_and_registered(qt_app): + assert qt_app.timeline_dock is not None + assert qt_app.timeline_dock.objectName() == "dock_timeline" + # The 2x2 grid puts the top row in the Top area and the bottom row + # (timeline | transport) in the Left area (see QtTTSApp.arrange_docks_default). + assert qt_app.dockWidgetArea(qt_app.timeline_dock) == Qt.DockWidgetArea.LeftDockWidgetArea + assert qt_app.dockWidgetArea(qt_app.transcript_dock) == Qt.DockWidgetArea.TopDockWidgetArea + + +def test_dock_renders_clips_already_present_at_startup(qt_app): + character = qt_app.document.characters[0] + qt_app.document.text = "hello world" + qt_app.document.assign_character_to_range(0, 5, character.id) + + qt_app.refresh_timeline() + + assert len(_clip_block_items(qt_app.timeline_dock)) == 1 + + +def test_real_edit_triggers_timeline_refresh(qt_app, monkeypatch): + calls = [] + monkeypatch.setattr(qt_app, "refresh_timeline", lambda: calls.append(True)) + editor = _editor(qt_app) + + _set_text_via_real_edit(editor, "hello world") + + assert calls + + +def test_assign_character_triggers_timeline_refresh_and_renders_block(qt_app, monkeypatch): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + + calls = [] + monkeypatch.setattr(qt_app, "refresh_timeline", lambda: calls.append(True)) + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.setPosition(5, QTextCursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + + editor._assign_character(character.id) + + assert calls + # refresh_timeline is monkeypatched to a no-op above, so drive a real + # refresh now to confirm the underlying clip is actually there. + qt_app.timeline_dock.refresh() + assert len(_clip_block_items(qt_app.timeline_dock)) == 1 + + +def test_paste_with_split_triggers_timeline_refresh(qt_app, monkeypatch): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + qt_app.settings["character_fx_paste_splits"] = True + + calls = [] + monkeypatch.setattr(qt_app, "refresh_timeline", lambda: calls.append(True)) + cursor = editor.textCursor() + cursor.setPosition(11) + editor.setTextCursor(cursor) + + editor.insertFromMimeData(_mime_with_character(" PASTED", character.id)) + + assert calls + + +def test_paste_without_split_still_refreshes_via_text_edit_path(qt_app, monkeypatch): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + qt_app.settings["character_fx_paste_splits"] = False + + calls = [] + monkeypatch.setattr(qt_app, "refresh_timeline", lambda: calls.append(True)) + cursor = editor.textCursor() + cursor.setPosition(11) + editor.setTextCursor(cursor) + + editor.insertFromMimeData(_mime_with_character(" PASTED", character.id)) + + # No clip-metadata mutation happens (splits disabled), but the plain + # text insertion still flows through _on_contents_change, which already + # calls refresh_timeline() unconditionally. + assert calls diff --git a/tests/gui_qt/test_qt_voice_clone_dock.py b/tests/gui_qt/test_qt_voice_clone_dock.py new file mode 100644 index 0000000..731e229 --- /dev/null +++ b/tests/gui_qt/test_qt_voice_clone_dock.py @@ -0,0 +1,311 @@ +"""Voice Reference dock: shown/hidden per-engine (supports_voice_cloning), +save/delete of wav+transcript references, and the auto-transcribe button. + +Switching to the "audio8" engine builds a *real* `Audio8Engine` (the +registry factory has no stub-swapping hook the way `qt_app`'s fixture +patches `KokoroEngine` -> `StubEngine`), so its model load +(`kokoro_gui.engines.audio8_tts._get_model`) is monkeypatched to a fast fake +before every switch - never touches `transformers`/downloads a model. +""" +import os + +import numpy as np +import soundfile as sf + +from kokoro_gui.engines import audio8_tts +from kokoro_gui.engines.audio8_tts import Audio8ReferenceStore + +# `kokoro_gui.qt.docks.voice_clone_dock` is deliberately never imported at +# this module's top level - `kokoro_gui.qt.app`/`kokoro_gui.qt.docks` have a +# documented circular-import relationship (see app.py's module docstring) +# that only resolves when `kokoro_gui.qt.app` is imported first, which the +# `qt_app` fixture guarantees but a bare top-level import here would not. +# `monkeypatch.setattr("module.path.attr", ...)` (string target) below +# imports the module lazily, at test-run time, after `qt_app` has already +# done so. +_TRANSCRIBE_TARGET = "kokoro_gui.qt.docks.voice_clone_dock.transcribe_wav" + + +def _switch_to_audio8(qt_app, monkeypatch): + monkeypatch.setattr(audio8_tts, "_get_model", lambda: (object(), object())) + qt_app.switch_engine("audio8") + + +def _write_wav(path): + audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1600) / 16000)).astype(np.float32) + sf.write(str(path), audio, 16000) + return str(path) + + +# --- show/hide on engine switch --------------------------------------------- + +def test_audio8_backend_shows_voice_clone_dock_and_hides_mixing(qt_app, monkeypatch): + _switch_to_audio8(qt_app, monkeypatch) + assert qt_app.backend.id == "audio8" + assert qt_app.voice_clone_dock is not None + assert qt_app.mixing_dock is None + + +def test_switch_back_to_kokoro_hides_voice_clone_dock_and_restores_mixing(qt_app, monkeypatch): + _switch_to_audio8(qt_app, monkeypatch) + qt_app.switch_engine("kokoro") + assert qt_app.voice_clone_dock is None + assert qt_app.mixing_dock is not None + + +def test_jit_streaming_disabled_falls_back_to_standard_start(qt_app, monkeypatch): + _switch_to_audio8(qt_app, monkeypatch) + qt_app.jit_enabled = True + # The Options menu's JIT toggle greys out for a backend without + # streaming, and start_conversion() takes the Standard path. + assert qt_app.jit_action.isEnabled() is False + from unittest.mock import MagicMock + monkeypatch.setattr(qt_app.engine, "start_conversion", MagicMock()) + monkeypatch.setattr(qt_app.engine, "start_jit_conversion", MagicMock()) + monkeypatch.setattr(qt_app.engine, "pipeline", True) + qt_app.editor.setPlainText("hello") + qt_app.start_conversion() + assert qt_app.engine.start_conversion.called + assert not qt_app.engine.start_jit_conversion.called + + +# --- save / delete reference ------------------------------------------------- + +def test_save_reference_appears_in_generation_voice_dropdown(qt_app, monkeypatch, tmp_path): + _switch_to_audio8(qt_app, monkeypatch) + wav_path = _write_wav(tmp_path / "ref.wav") + + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText(wav_path) + dock.transcript_edit.setPlainText("Hello world reference.") + dock.name_edit.setText("Fred") + dock._on_save_clicked() + + assert Audio8ReferenceStore.list_references() == ["Fred"] + + combo = qt_app.settings_dock.schema_form.widget_for("voice") + items = [combo.itemData(i) for i in range(combo.count())] + assert "Fred" in items + + +def test_save_reference_rejects_missing_name(qt_app, monkeypatch, tmp_path): + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText(_write_wav(tmp_path / "ref.wav")) + dock.transcript_edit.setPlainText("Some transcript.") + dock.name_edit.setText("") + + dock._on_save_clicked() + + assert Audio8ReferenceStore.list_references() == [] + + +def test_delete_reference_removes_it_and_dropdown_entry(qt_app, monkeypatch, tmp_path): + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText(_write_wav(tmp_path / "ref.wav")) + dock.transcript_edit.setPlainText("Some transcript.") + dock.name_edit.setText("Ghost") + dock._on_save_clicked() + assert Audio8ReferenceStore.list_references() == ["Ghost"] + + # qt_app fixture patches QMessageBox.question -> Yes globally. + dock.delete_reference("Ghost") + + assert Audio8ReferenceStore.list_references() == [] + combo = qt_app.settings_dock.schema_form.widget_for("voice") + items = [combo.itemData(i) for i in range(combo.count())] + assert "Ghost" not in items + + +def test_load_reference_populates_editable_fields(qt_app, monkeypatch, tmp_path): + _switch_to_audio8(qt_app, monkeypatch) + wav_path = _write_wav(tmp_path / "ref.wav") + Audio8ReferenceStore.save_reference("Loaded", wav_path, "Original text.") + + dock = qt_app.voice_clone_dock + dock.refresh_list() + dock._load_reference("Loaded") + + assert dock.name_edit.text() == "Loaded" + assert dock.transcript_edit.toPlainText() == "Original text." + assert dock.wav_path_edit.text().endswith("Loaded.wav") + + +# --- auto-transcribe ---------------------------------------------------- + +def test_auto_transcribe_populates_transcript(qt_app, monkeypatch, tmp_path, qtbot): + _switch_to_audio8(qt_app, monkeypatch) + monkeypatch.setattr(_TRANSCRIBE_TARGET, lambda path, **kwargs: "Fake transcript text.") + + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText(_write_wav(tmp_path / "ref.wav")) + + dock._on_transcribe_clicked() + + qtbot.waitUntil(lambda: dock.transcript_edit.toPlainText() != "", timeout=5000) + assert dock.transcript_edit.toPlainText() == "Fake transcript text." + assert dock.transcribe_btn.isEnabled() + + +def test_auto_transcribe_failure_shows_status_and_reenables_button(qt_app, monkeypatch, tmp_path, qtbot): + _switch_to_audio8(qt_app, monkeypatch) + + def _boom(path, **kwargs): + raise RuntimeError("model unavailable") + + monkeypatch.setattr(_TRANSCRIBE_TARGET, _boom) + + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText(_write_wav(tmp_path / "ref.wav")) + + dock._on_transcribe_clicked() + + qtbot.waitUntil(lambda: dock.transcribe_btn.isEnabled(), timeout=5000) + assert "failed" in dock.status_label.text().lower() + assert dock.transcript_edit.toPlainText() == "" + + +def test_auto_transcribe_without_wav_selected_is_a_noop(qt_app, monkeypatch): + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText("") + + dock._on_transcribe_clicked() + + assert dock.transcript_edit.toPlainText() == "" + + +# --- ASR engine picker ---------------------------------------------------- + +def test_default_engine_is_audio8_and_vosk_row_hidden(qt_app, monkeypatch): + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + + assert dock.asr_engine_combo.currentData() == "audio8" + # isHidden() (not isVisible()) - the dock's never .show()n in this + # offscreen test, so isVisible() would be False for everything + # regardless of our explicit setVisible() calls. + assert dock.vosk_row.isHidden() + + +def test_selecting_vosk_shows_model_row(qt_app, monkeypatch): + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + + idx = dock.asr_engine_combo.findData("vosk") + dock.asr_engine_combo.setCurrentIndex(idx) + + assert not dock.vosk_row.isHidden() + + +def test_transcribe_with_vosk_passes_engine_and_typed_model_path(qt_app, monkeypatch, tmp_path, qtbot): + monkeypatch.delenv("VOSK_MODEL_PATH", raising=False) + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText(_write_wav(tmp_path / "ref.wav")) + dock.asr_engine_combo.setCurrentIndex(dock.asr_engine_combo.findData("vosk")) + # Typed but not Saved - transcribing shouldn't require a save first. + dock.vosk_model_edit.setText(str(tmp_path / "some-vosk-model")) + + seen = {} + + def _fake_transcribe(path, **kwargs): + seen.update(kwargs) + return "Vosk transcript." + + monkeypatch.setattr(_TRANSCRIBE_TARGET, _fake_transcribe) + dock._on_transcribe_clicked() + + qtbot.waitUntil(lambda: dock.transcript_edit.toPlainText() != "", timeout=5000) + assert dock.transcript_edit.toPlainText() == "Vosk transcript." + assert seen == {"engine": "vosk", "model_path": str(tmp_path / "some-vosk-model")} + + +def test_transcribe_with_vosk_and_empty_model_field_is_a_noop(qt_app, monkeypatch, tmp_path): + monkeypatch.delenv("VOSK_MODEL_PATH", raising=False) + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + dock.wav_path_edit.setText(_write_wav(tmp_path / "ref.wav")) + dock.asr_engine_combo.setCurrentIndex(dock.asr_engine_combo.findData("vosk")) + dock.vosk_model_edit.setText("") + + dock._on_transcribe_clicked() + + assert dock.transcript_edit.toPlainText() == "" + + +def test_vosk_model_field_reflects_env_var_on_open(qt_app, monkeypatch, tmp_path): + monkeypatch.setenv("VOSK_MODEL_PATH", str(tmp_path / "my-model")) + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + + assert dock.vosk_model_edit.text() == str(tmp_path / "my-model") + + +def test_vosk_model_field_empty_when_env_var_unset(qt_app, monkeypatch): + monkeypatch.delenv("VOSK_MODEL_PATH", raising=False) + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + + assert dock.vosk_model_edit.text() == "" + + +def test_save_writes_typed_path_to_dotenv(qt_app, monkeypatch, tmp_path): + monkeypatch.delenv("VOSK_MODEL_PATH", raising=False) + monkeypatch.chdir(tmp_path) + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + dock.vosk_model_edit.setText(str(tmp_path / "typed-model")) + + dock._save_vosk_model_path() + + assert os.environ["VOSK_MODEL_PATH"] == str(tmp_path / "typed-model") + assert "typed-model" in (tmp_path / ".env").read_text() + assert "Saved" in dock.status_label.text() + + +def test_browse_sets_field_and_saves(qt_app, monkeypatch, tmp_path): + monkeypatch.delenv("VOSK_MODEL_PATH", raising=False) + monkeypatch.chdir(tmp_path) + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + + browsed = tmp_path / "browsed-model" + monkeypatch.setattr( + "kokoro_gui.qt.docks.voice_clone_dock.QFileDialog.getExistingDirectory", + lambda *a, **k: str(browsed), + ) + dock._browse_vosk_model() + + assert dock.vosk_model_edit.text() == str(browsed) + assert os.environ["VOSK_MODEL_PATH"] == str(browsed) + assert "browsed-model" in (tmp_path / ".env").read_text() + + +def test_reload_discards_unsaved_edit_and_rereads_env(qt_app, monkeypatch, tmp_path): + # No .env file in this cwd - reload_vosk_model_path() finds nothing to + # re-read and leaves os.environ (set below) alone, so this isolates the + # test from whatever real .env file the repo root might actually have. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("VOSK_MODEL_PATH", str(tmp_path / "saved-model")) + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + assert dock.vosk_model_edit.text() == str(tmp_path / "saved-model") + + dock.vosk_model_edit.setText(str(tmp_path / "unsaved-typed-path")) + + dock._reload_vosk_model_path() + + assert dock.vosk_model_edit.text() == str(tmp_path / "saved-model") + + +def test_asr_engine_choice_persists_via_get_state(qt_app, monkeypatch): + _switch_to_audio8(qt_app, monkeypatch) + dock = qt_app.voice_clone_dock + dock.asr_engine_combo.setCurrentIndex(dock.asr_engine_combo.findData("vosk")) + + assert dock.get_state() == {"asr_engine": "vosk"} + + qt_app.save_settings() + assert qt_app.settings["asr_engine"] == "vosk" diff --git a/tests/gui_qt/test_selection_model.py b/tests/gui_qt/test_selection_model.py new file mode 100644 index 0000000..1039c0e --- /dev/null +++ b/tests/gui_qt/test_selection_model.py @@ -0,0 +1,117 @@ +"""Tests for kokoro_gui/qt/selection.py's SelectionModel - pure state-machine +tests, no qt_app fixture needed (lives under tests/gui_qt/ to inherit that +directory's conftest.py's PySide6 import-guard/offscreen-platform setup).""" +import pytest + +from kokoro_gui.qt.selection import SelectionModel + + +def _count_calls(model): + calls = [] + model.changed.connect(lambda: calls.append(True)) + return calls + + +def test_initial_state_is_none(): + model = SelectionModel() + assert model.kind == "none" + assert model.selected_clip_id is None + assert model.selected_character_id is None + assert model.selected_range is None + + +def test_select_clip_sets_kind_and_clears_others(): + model = SelectionModel() + model.select_character("char-1") + model.select_clip("clip-1") + assert model.kind == "clip" + assert model.selected_clip_id == "clip-1" + assert model.selected_character_id is None + assert model.selected_range is None + + +def test_select_character_sets_kind_and_clears_others(): + model = SelectionModel() + model.select_clip("clip-1") + model.select_character("char-1") + assert model.kind == "character" + assert model.selected_character_id == "char-1" + assert model.selected_clip_id is None + assert model.selected_range is None + + +def test_select_range_sets_kind_and_clears_others(): + model = SelectionModel() + model.select_clip("clip-1") + model.select_range(2, 8) + assert model.kind == "range" + assert model.selected_range == (2, 8) + assert model.selected_clip_id is None + assert model.selected_character_id is None + + +def test_clear_resets_to_none(): + model = SelectionModel() + model.select_clip("clip-1") + model.clear() + assert model.kind == "none" + assert model.selected_clip_id is None + + +def test_changed_emits_once_per_real_state_change(): + model = SelectionModel() + calls = _count_calls(model) + + model.select_clip("clip-1") + assert len(calls) == 1 + + model.select_character("char-1") + assert len(calls) == 2 + + model.select_range(0, 3) + assert len(calls) == 3 + + model.clear() + assert len(calls) == 4 + + +def test_repeated_mutator_call_with_same_value_does_not_reemit(): + model = SelectionModel() + model.select_clip("clip-1") + calls = _count_calls(model) + + model.select_clip("clip-1") + + assert calls == [] + assert model.selected_clip_id == "clip-1" + + +def test_repeated_clear_does_not_reemit(): + model = SelectionModel() + calls = _count_calls(model) + + model.clear() + + assert calls == [] + + +def test_repeated_select_range_with_same_bounds_does_not_reemit(): + model = SelectionModel() + model.select_range(5, 10) + calls = _count_calls(model) + + model.select_range(5, 10) + + assert calls == [] + + +def test_select_range_with_equal_bounds_raises_value_error(): + model = SelectionModel() + with pytest.raises(ValueError): + model.select_range(5, 5) + + +def test_select_range_with_end_before_start_raises_value_error(): + model = SelectionModel() + with pytest.raises(ValueError): + model.select_range(5, 2) diff --git a/tests/gui_qt/test_selection_sync_reentrancy.py b/tests/gui_qt/test_selection_sync_reentrancy.py new file mode 100644 index 0000000..9b9a027 --- /dev/null +++ b/tests/gui_qt/test_selection_sync_reentrancy.py @@ -0,0 +1,80 @@ +"""Regression test for item 1 ("Sync layer"): a single user action must not +ping-pong between TranscriptEditor and TimelineView's selection-changed +handlers. Full qt_app fixture; uses the same call-counting idiom already used +elsewhere in this suite (e.g. tests/gui_qt/test_qt_timeline_dock.py's +`monkeypatch.setattr(qt_app, "refresh_timeline", lambda: calls.append(True))`), +adapted here via disconnect/reconnect since the handlers under test are +already bound as signal slots at qt_app construction time (a class-level +monkeypatch after construction would not affect an already-bound +connection).""" +from PySide6.QtCore import Qt +from PySide6.QtGui import QTextCursor + +from kokoro_gui.qt.timeline_view import ClipBlockItem + + +def _editor(qt_app): + return qt_app.editor + + +def _set_text_via_real_edit(editor, text): + cursor = editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(text) + + +def _clip_block_items(qt_app): + return [item for item in qt_app.timeline_dock.timeline_view._scene.items() if isinstance(item, ClipBlockItem)] + + +def _instrument(signal, original_slot): + """Disconnects `original_slot` from `signal` and reconnects a + call-counting wrapper that still invokes it, returning the calls list.""" + calls = [] + signal.disconnect(original_slot) + + def wrapper(): + calls.append(True) + original_slot() + + signal.connect(wrapper) + return calls + + +def test_timeline_click_fires_each_handler_at_most_once(qt_app, qtbot): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(6, 11, character.id) # "world" + qt_app.refresh_timeline() + + view = qt_app.timeline_dock.timeline_view + editor_calls = _instrument(qt_app.selection.changed, editor._on_selection_model_changed) + timeline_calls = _instrument(qt_app.selection.changed, view._on_selection_changed) + + block = _clip_block_items(qt_app)[0] + assert block.clip_id == clip.id + pos = view.mapFromScene(block.mapToScene(5, 5)) + qtbot.mouseClick(view.viewport(), Qt.MouseButton.LeftButton, pos=pos) + + assert len(editor_calls) == 1 + assert len(timeline_calls) == 1 + + +def test_transcript_cursor_move_into_clip_fires_each_handler_at_most_once(qt_app, qtbot): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + qt_app.document.assign_character_to_range(6, 11, character.id) # "world" + qt_app.refresh_timeline() + + view = qt_app.timeline_dock.timeline_view + editor_calls = _instrument(qt_app.selection.changed, editor._on_selection_model_changed) + timeline_calls = _instrument(qt_app.selection.changed, view._on_selection_changed) + + cursor = editor.textCursor() + cursor.setPosition(8) # inside "world" + editor.setTextCursor(cursor) + + assert len(editor_calls) == 1 + assert len(timeline_calls) == 1 diff --git a/tests/gui_qt/test_settings_dock.py b/tests/gui_qt/test_settings_dock.py new file mode 100644 index 0000000..821d282 --- /dev/null +++ b/tests/gui_qt/test_settings_dock.py @@ -0,0 +1,205 @@ +"""Tests for kokoro_gui/qt/docks/settings_dock.py's SettingsDock - item 2 +("Settings panel rescoping") of the DAW-for-text redesign's remaining-work +roadmap. Three states keyed off `SelectionModel.kind` ("none"/"clip"/ +"character"), each sourcing/writing a different backing store - see that +module's docstring for the full contract. +""" +from kokoro_gui.daw import dirty +from kokoro_gui.daw.models import Segment +from kokoro_gui.qt import spec + + +def _make_clip(qt_app, start=0, end=5, text="hello world"): + qt_app.document.text = text + character = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(start, end, character.id) + return clip, character + + +# --------------------------------------------------------------------------- +# "none" mode - regression-pin today's migrated (pre-rescoping) behavior +# --------------------------------------------------------------------------- + +def test_none_mode_is_the_default_selection_state(qt_app): + assert qt_app.selection.kind == "none" + assert qt_app.settings_dock._mode == "none" + + +def test_none_mode_get_state_reflects_live_widget_edits(qt_app): + qt_app.settings_dock.volume_spin.setValue(1.75) + qt_app.settings_dock.schema_form.set_values({"speed": 1.3}) + + state = qt_app.settings_dock.get_state() + + assert state["volume"] == 1.75 + assert state["speed"] == 1.3 + + +def test_none_mode_fields_are_all_enabled(qt_app): + for key in ("lang_code", "voice", "speed", "num_threads", "caching"): + widget = qt_app.settings_dock.schema_form.widget_for(key) + assert widget is not None + assert widget.isEnabled() is True + + +# --------------------------------------------------------------------------- +# "clip" mode +# --------------------------------------------------------------------------- + +def test_clip_mode_renders_effective_config_for_clip(qt_app): + clip, character = _make_clip(qt_app) + character.preset_data["voice"] = "af_bella" + character.preset_data["speed"] = 1.4 + + qt_app.selection.select_clip(clip.id) + + assert qt_app.settings_dock._mode == "clip" + values = qt_app.settings_dock.schema_form.values() + assert values["voice"] == "af_bella" + assert values["speed"] == 1.4 + + +def test_clip_mode_disables_non_allowed_preset_fields_but_not_allowed_ones(qt_app): + clip, _character = _make_clip(qt_app) + + qt_app.selection.select_clip(clip.id) + + threads_widget = qt_app.settings_dock.schema_form.widget_for("num_threads") + caching_widget = qt_app.settings_dock.schema_form.widget_for("caching") + lang_widget = qt_app.settings_dock.schema_form.widget_for("lang_code") + voice_widget = qt_app.settings_dock.schema_form.widget_for("voice") + speed_widget = qt_app.settings_dock.schema_form.widget_for("speed") + + assert threads_widget.isEnabled() is False + assert caching_widget.isEnabled() is False + assert lang_widget.isEnabled() is False + assert voice_widget.isEnabled() is True + assert speed_widget.isEnabled() is True + + +def test_editing_clip_mode_schema_field_writes_to_overrides_not_settings(qt_app): + clip, _character = _make_clip(qt_app) + qt_app.selection.select_clip(clip.id) + original_settings_speed = qt_app.settings.get("speed") + + qt_app.settings_dock.schema_form.set_values({"speed": 1.9}) + # set_values() drives the same on_change path as a real user edit. + qt_app.settings_dock._on_schema_field_changed("speed", 1.9) + + assert clip.overrides.get("speed") == 1.9 + assert qt_app.settings.get("speed") == original_settings_speed + + +def test_editing_clip_mode_hand_built_widget_writes_to_overrides_not_settings(qt_app): + clip, _character = _make_clip(qt_app) + qt_app.selection.select_clip(clip.id) + original_settings_volume = qt_app.settings.get("volume") + + qt_app.settings_dock.volume_spin.setValue(1.6) + + assert clip.overrides.get("volume") == 1.6 + assert qt_app.settings.get("volume") == original_settings_volume + + +def test_stale_clip_selection_falls_back_to_none(qt_app): + qt_app.selection.select_clip("does-not-exist") + + assert qt_app.settings_dock._mode == "none" + assert qt_app.settings_dock.schema_form is not None + + +# --------------------------------------------------------------------------- +# "character" mode + Q7 propagation +# --------------------------------------------------------------------------- + +def test_character_mode_renders_preset_data(qt_app): + character = qt_app.document.characters[0] + character.preset_data["voice"] = "af_sarah" + + qt_app.selection.select_character(character.id) + + assert qt_app.settings_dock._mode == "character" + assert qt_app.settings_dock.schema_form.values()["voice"] == "af_sarah" + + +def test_character_edit_propagates_to_non_overridden_clip_but_not_overridden_one(qt_app): + qt_app.document.text = "hello world here" + character = qt_app.document.characters[0] + clip_no_override = qt_app.document.assign_character_to_range(0, 5, character.id) + clip_with_override = qt_app.document.assign_character_to_range(6, 11, character.id) + clip_with_override.overrides["speed"] = 2.0 + + qt_app.selection.select_character(character.id) + qt_app.settings_dock.schema_form.set_values({"speed": 1.7}) + qt_app.settings_dock._on_schema_field_changed("speed", 1.7) + + assert character.preset_data["speed"] == 1.7 + assert qt_app.document.effective_config_for_clip(clip_no_override)["speed"] == 1.7 + assert qt_app.document.effective_config_for_clip(clip_with_override)["speed"] == 2.0 + + +def test_stale_character_selection_falls_back_to_none(qt_app): + qt_app.selection.select_character("does-not-exist") + + assert qt_app.settings_dock._mode == "none" + assert qt_app.settings_dock.schema_form is not None + + +# --------------------------------------------------------------------------- +# dirty-state flips with no explicit dirty-marking call +# --------------------------------------------------------------------------- + +def test_editing_clip_config_flips_dirty_state_with_no_explicit_marking(qt_app): + clip, _character = _make_clip(qt_app) + text = qt_app.document.clip_text(clip) + config = qt_app.document.effective_config_for_clip(clip) + cache_hash = dirty.compute_expected_cache_hash(text, config) + clip.segments = [Segment(order_index=0, text=text, cache_key=cache_hash)] + assert dirty.is_clip_dirty(clip, text, qt_app.document.effective_config_for_clip(clip)) is False + + qt_app.selection.select_clip(clip.id) + qt_app.settings_dock.schema_form.set_values({"speed": 1.5}) + qt_app.settings_dock._on_schema_field_changed("speed", 1.5) + + assert dirty.is_clip_dirty( + clip, qt_app.document.clip_text(clip), qt_app.document.effective_config_for_clip(clip) + ) is True + + +# --------------------------------------------------------------------------- +# GenerationDock.get_state() still supplies everything app.py's config +# assembly reads directly by key. +# --------------------------------------------------------------------------- + +def test_generation_dock_state_covers_base_keys_minus_settings_owned(qt_app): + state = qt_app.settings_dock.get_state() + settings_owned = {"engine_id", "time_id", "lexicon"} + # Output/format/subtitles/keep-segments live in the Export dialog now + # (kokoro_gui/qt/docks/export_dialog.py), not the Settings tab. + export_owned = {"filename", "out_dir", "separate", "combine", "export_subtitles"} + assert set(state.keys()) | settings_owned | export_owned == set(spec.GENERATION_BASE_KEYS) + + +def test_assemble_config_does_not_raise_key_error(qt_app): + config = qt_app._assemble_config() + assert config["voice"] + + +def test_assemble_clip_config_does_not_raise_key_error(qt_app): + clip, _character = _make_clip(qt_app) + config = qt_app._assemble_clip_config(clip) + assert config["voice"] + + +def test_assemble_config_unaffected_by_a_selected_clip(qt_app): + """Whole-document config assembly must keep using project-wide defaults + even while a clip happens to be selected in the UI, not whatever that + clip's character happens to resolve to.""" + qt_app.settings_dock.schema_form.set_values({"voice": "af_sarah"}) + clip, character = _make_clip(qt_app) + character.preset_data["voice"] = "af_bella" + + qt_app.selection.select_clip(clip.id) + config = qt_app._assemble_config() + + assert config["voice"] == "af_sarah" diff --git a/tests/gui_qt/test_shell.py b/tests/gui_qt/test_shell.py new file mode 100644 index 0000000..400fc7d --- /dev/null +++ b/tests/gui_qt/test_shell.py @@ -0,0 +1,245 @@ +"""Tests for the reshaped shell (Claude/PLAN_ui_shell_redesign.md section +1): menu bar, 2x2 dock grid, Transport dock, workspaces, theme, Options.""" +from PySide6.QtCore import Qt + +from kokoro_gui.qt import theme +from kokoro_gui.qt.workspace import ADVANCED, SIMPLE + + +def _menu_titles(app): + return [a.text().replace("&", "") for a in app.menuBar().actions()] + + +def _action_texts(menu): + return [a.text().replace("&", "") for a in menu.actions() if not a.isSeparator()] + + +def test_menu_bar_has_the_four_menus(qt_app): + assert _menu_titles(qt_app) == ["File", "Edit", "Options", "Workspace"] + + +def test_file_menu_actions(qt_app): + texts = _action_texts(qt_app.file_menu) + assert texts == ["New", "Open...", "Recent", "Welcome...", "Save", "Save As...", "Import Text...", + "Import Audio...", "Export...", "Quit"] + assert qt_app.import_audio_action.isEnabled() is False + assert "ASR" in qt_app.import_audio_action.toolTip() + + +def test_edit_menu_actions(qt_app): + assert _action_texts(qt_app.edit_menu) == ["Undo", "Redo", "Cut", "Copy", "Paste", "Characters..."] + + +def test_options_menu_holds_engine_device_theme_and_toggles(qt_app): + texts = _action_texts(qt_app.options_menu) + assert texts[:3] == ["Engine", "Device", "Theme"] + assert "Copy carries character/FX" in texts + assert "Paste splits character/FX" in texts + assert any(t.startswith("JIT streaming") for t in texts) + assert set(qt_app.device_actions) == {"auto", "cpu", "cuda"} + assert set(qt_app.theme_actions) == {"light", "dark"} + + +def test_no_toolbar_and_hidden_central_widget(qt_app): + from PySide6.QtWidgets import QToolBar + + assert qt_app.findChildren(QToolBar) == [] + central = qt_app.centralWidget() + assert central.isHidden() + # Not fixed to 0x0: a fixed central widget caps the height of the row it + # sits in, and the timeline could never be dragged taller. + assert central.maximumHeight() > 0 + + +def test_timeline_row_can_be_made_taller(qt_app, qtbot): + qt_app.resize(1600, 1000) + qt_app.show() + qtbot.waitExposed(qt_app) + qtbot.wait(50) # let the first-show default-proportions pass run + before = qt_app.timeline_dock.height() + qt_app.resizeDocks([qt_app.transcript_dock, qt_app.timeline_dock], [300, 650], Qt.Orientation.Vertical) + qtbot.wait(20) + assert qt_app.timeline_dock.height() > before + 100 + + +def test_all_panels_are_docks_in_the_grid(qt_app): + top, bottom = Qt.DockWidgetArea.TopDockWidgetArea, Qt.DockWidgetArea.LeftDockWidgetArea + for dock in (qt_app.transcript_dock, qt_app.settings_dock, qt_app.fx_dock, qt_app.lexicon_dock): + assert not dock.isFloating() + assert qt_app.dockWidgetArea(dock) == top + for dock in (qt_app.timeline_dock, qt_app.transport_dock): + assert not dock.isFloating() + assert qt_app.dockWidgetArea(dock) == bottom + tabbed = qt_app.tabifiedDockWidgets(qt_app.settings_dock) + assert qt_app.fx_dock in tabbed and qt_app.lexicon_dock in tabbed + assert qt_app.mixing_dock in tabbed # Kokoro -> Mixing behind the "Voices" tab + assert qt_app.mixing_dock.windowTitle() == "Voices" + assert qt_app.mixing_dock.objectName() == "dock_voices" + + +def test_voices_tab_keeps_its_title_across_engines(qt_app): + qt_app.switch_engine("dummy") + assert qt_app.mixing_dock is None and qt_app.voice_clone_dock is None + qt_app.switch_engine("kokoro") + assert qt_app.mixing_dock.windowTitle() == "Voices" + assert qt_app.mixing_dock in qt_app.tabifiedDockWidgets(qt_app.settings_dock) + + +# -- transport dock ----------------------------------------------------------- + + +def test_transport_dock_carries_generate_menu_and_status(qt_app): + dock = qt_app.transport_dock + assert [a.text() for a in dock.generate_menu.actions() if not a.isSeparator()] == [ + "Generate dirty clips", "Auto-split then generate", "Split by paragraph", + ] + dock.set_status("Hello", "error") + assert dock.status_text() == "Hello" + assert "#ff5555" in dock.progress_bar.styleSheet() + dock.set_progress(42, "clip 3/5", elapsed=61, eta="00:10") + assert dock.progress_bar.value() == 42 + assert "clip 3/5" in dock.progress_bar.format() + assert "01:01" in dock.progress_bar.format() + + +def test_busy_state_disables_generate_and_enables_cancel(qt_app): + qt_app.set_ui_state(True) + assert qt_app.is_busy() + assert not qt_app.transport_dock.generate_btn.isEnabled() + assert qt_app.transport_dock.cancel_btn.isEnabled() + qt_app.set_ui_state(False) + assert not qt_app.is_busy() + assert qt_app.transport_dock.generate_btn.isEnabled() + + +def test_split_by_paragraph_action_writes_the_setting(qt_app): + qt_app.transport_dock.split_paragraph_action.setChecked(True) + assert qt_app.settings["auto_split_by_paragraph"] is True + qt_app.transport_dock.split_paragraph_action.setChecked(False) + assert qt_app.settings["auto_split_by_paragraph"] is False + + +# -- workspaces ---------------------------------------------------------------- + + +def test_default_workspace_is_advanced_with_timeline_visible(qt_app): + assert qt_app.workspaces.active == ADVANCED + assert qt_app.workspace_actions[ADVANCED].isChecked() + assert not qt_app.timeline_dock.isHidden() + + +def test_simple_workspace_hides_the_timeline_only(qt_app): + qt_app.activate_workspace(SIMPLE) + assert qt_app.timeline_dock.isHidden() + assert not qt_app.transport_dock.isHidden() + assert not qt_app.transcript_dock.isHidden() + assert qt_app.settings["active_workspace"] == SIMPLE + assert qt_app.workspace_actions[SIMPLE].isChecked() + + qt_app.activate_workspace(ADVANCED) + assert not qt_app.timeline_dock.isHidden() + + +def test_workspace_edits_are_captured_per_workspace_and_reset_forgets_them(qt_app): + qt_app.timeline_dock.hide() # a "drag edit" in Advanced + qt_app.save_settings() + assert qt_app.settings["workspaces"][ADVANCED]["state"] + + qt_app.activate_workspace(SIMPLE) + qt_app.activate_workspace(ADVANCED) + assert qt_app.timeline_dock.isHidden() # the saved edit came back + + qt_app.reset_workspace() + assert not qt_app.timeline_dock.isHidden() + assert ADVANCED not in qt_app.settings["workspaces"] or qt_app.settings["workspaces"][ADVANCED]["state"] + + +def test_legacy_dock_state_keys_migrate_into_advanced(qt_app): + from kokoro_gui.qt.workspace import WorkspaceManager + + settings = {"dock_state": "AAAA", "geometry": "BBBB"} + WorkspaceManager(qt_app, settings) + assert "dock_state" not in settings and "geometry" not in settings + assert settings["workspaces"][ADVANCED] == {"state": "AAAA", "geometry": "BBBB"} + assert settings["active_workspace"] == ADVANCED + + +# -- theme -------------------------------------------------------------------------- + + +def test_theme_switch_updates_palette_setting_and_signals(qt_app): + fired = [] + qt_app.themeChanged.connect(lambda: fired.append(True)) + + qt_app.set_theme("dark") + + assert qt_app.settings["theme"] == "dark" + assert theme.current() is theme.DARK + assert qt_app.theme_actions["dark"].isChecked() + assert fired == [True] + qt_app.set_theme("light") + assert theme.current() is theme.LIGHT + + +def test_theme_tokens_are_complete_in_both_palettes(): + from dataclasses import fields + + for pal in (theme.LIGHT, theme.DARK): + for f in fields(theme.Palette): + value = getattr(pal, f.name) + assert value, f"{pal.name}.{f.name} is empty" + + +# -- options --------------------------------------------------------------------------- + + +def test_device_action_writes_setting_and_reinitializes(qt_app): + qt_app.engine.init_pipeline_async.reset_mock() + qt_app.set_device("cpu") + assert qt_app.settings["device"] == "cpu" + assert qt_app.device_actions["cpu"].isChecked() + qt_app.engine.init_pipeline_async.assert_called_with("a", device="cpu") + + +def test_copy_carries_toggle_controls_the_character_mime_type(qt_app): + from PySide6.QtGui import QTextCursor + + editor = qt_app.editor + cursor = editor.textCursor() + cursor.insertText("hello world") + alice = qt_app.document.characters[0] + qt_app.document.assign_character_to_range(0, 5, alice.id) + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.setPosition(5, QTextCursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + + assert editor.createMimeDataFromSelection().hasFormat(editor.CHARACTER_ID_MIME_TYPE) + qt_app.copy_carries_action.setChecked(False) + assert qt_app.settings["character_fx_copy"] is False + assert not editor.createMimeDataFromSelection().hasFormat(editor.CHARACTER_ID_MIME_TYPE) + + +def test_space_shortcuts_toggle_the_transport(qt_app): + toggles = [] + qt_app.transport.toggle = lambda: toggles.append(True) + qt_app.space_shortcut.activated.disconnect() + qt_app.ctrl_space_shortcut.activated.disconnect() + qt_app.space_shortcut.activated.connect(qt_app.transport.toggle) + qt_app.ctrl_space_shortcut.activated.connect(qt_app.transport.toggle) + qt_app.space_shortcut.activated.emit() + qt_app.ctrl_space_shortcut.activated.emit() + assert toggles == [True, True] + assert qt_app.space_shortcut.key().toString() == "Space" + assert qt_app.ctrl_space_shortcut.key().toString() == "Ctrl+Space" + + +def test_window_title_names_the_project_and_marks_pending_saves(qt_app): + assert qt_app.windowTitle() == "Untitled - KokoroGUI" + qt_app.schedule_save() + assert qt_app.windowTitle() == "Untitled* - KokoroGUI" + qt_app.save_settings() # nothing changed: the digest matches, so no star + assert qt_app.windowTitle() == "Untitled - KokoroGUI" + qt_app.document.text = "an edit" + qt_app.save_settings() + assert qt_app.windowTitle() == "Untitled* - KokoroGUI" diff --git a/tests/gui_qt/test_sub_range_tts_replace.py b/tests/gui_qt/test_sub_range_tts_replace.py new file mode 100644 index 0000000..8724cfb --- /dev/null +++ b/tests/gui_qt/test_sub_range_tts_replace.py @@ -0,0 +1,170 @@ +"""Tests for item 9 ("Sub-range TTS replacement"): +`TimelineDock.on_sub_range_tts_requested` - the dialog offering an editable +sub-range transcript + any-character picker, sequencing a `TextEditCommand` +(if the text was edited) before an `AssignCharacterCommand`, then dispatching +item 3's `generate_dirty_clips_requested()`. Mirrors +tests/gui_qt/test_timeline_drag_reassign.py's conventions (qt_app fixture, +driving the dock's handler directly, monkeypatching the blocking modal so a +test can supply canned "user typed X and picked character Y" input without a +real dialog blocking). +""" +from PySide6.QtWidgets import QComboBox, QDialog, QPlainTextEdit + +from kokoro_gui.daw.models import Character + + +def _make_whole_document_clip(qt_app, text="The quick brown fox jumps"): + qt_app.document.text = text + alice = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(0, len(text), alice.id) + return clip, alice + + +def _accept_dialog_with(new_text, character_id): + """A QDialog.exec replacement that reaches into the dialog's own + QPlainTextEdit/QComboBox (found via findChild, same as the real + on_sub_range_tts_requested does after a real exec()) to set the "user + typed X and picked character Y" state before resolving Accepted - same + precedent as test_timeline_drag_reassign.py's QMessageBox.exec/ + clickedButton monkeypatching.""" + def _exec(self): + self.findChild(QPlainTextEdit).setPlainText(new_text) + combo = self.findChild(QComboBox) + index = combo.findData(character_id) + if index >= 0: + combo.setCurrentIndex(index) + return QDialog.DialogCode.Accepted + return _exec + + +def _reject_dialog(self): + return QDialog.DialogCode.Rejected + + +# --------------------------------------------------------------------------- +# Same text, a different character than the clip's own (Q27). +# --------------------------------------------------------------------------- + +def test_same_text_different_character_creates_sub_clip_and_leaves_remainder(qt_app, monkeypatch): + text = "The quick brown fox jumps" + clip, alice = _make_whole_document_clip(qt_app, text) + bob = Character.from_preset_dict("Bob", {}) + qt_app.document.characters.append(bob) + + sub_start, sub_end = 4, 9 # "quick" + original_fragment = text[sub_start:sub_end] + monkeypatch.setattr(QDialog, "exec", _accept_dialog_with(original_fragment, bob.id)) + + qt_app.timeline_dock.on_sub_range_tts_requested(clip.id, sub_start, sub_end) + + assert qt_app.document.text == text # no text edit happened + + new_clip = next( + c for c in qt_app.document.clips if qt_app.document.clip_extent(c.id) == (sub_start, sub_end) + ) + assert new_clip.character_id == bob.id + + remainder = [c for c in qt_app.document.clips if c.id != new_clip.id] + assert remainder # at least one leftover fragment + assert all(c.character_id == alice.id for c in remainder) + covered = sorted(qt_app.document.clip_extent(c.id) for c in remainder) + assert covered == [(0, sub_start), (sub_end, len(text))] + + assert qt_app.engine.generate_dirty_clips.called + + +def test_chosen_character_wins_over_parent_clips_own_character(qt_app, monkeypatch): + """Q27's core claim, isolated: the new sub-clip's character is whichever + one was explicitly picked in the dialog, never silently inherited from + the parent clip.""" + text = "One two three four five" + clip, alice = _make_whole_document_clip(qt_app, text) + carol = Character.from_preset_dict("Carol", {}) + qt_app.document.characters.append(carol) + assert clip.character_id == alice.id + + sub_start, sub_end = 0, 3 # "One" + original_fragment = text[sub_start:sub_end] + monkeypatch.setattr(QDialog, "exec", _accept_dialog_with(original_fragment, carol.id)) + + qt_app.timeline_dock.on_sub_range_tts_requested(clip.id, sub_start, sub_end) + + new_clip = next( + c for c in qt_app.document.clips if qt_app.document.clip_extent(c.id) == (sub_start, sub_end) + ) + assert new_clip.character_id == carol.id + assert new_clip.character_id != alice.id + + +# --------------------------------------------------------------------------- +# Edited text. +# --------------------------------------------------------------------------- + +def test_edited_text_updates_document_and_resyncs_transcript_editor(qt_app, monkeypatch): + text = "The quick brown fox jumps" + clip, alice = _make_whole_document_clip(qt_app, text) + + sub_start, sub_end = 4, 9 # "quick" + new_fragment = "slow" # shorter than the original "quick" + monkeypatch.setattr(QDialog, "exec", _accept_dialog_with(new_fragment, alice.id)) + + qt_app.timeline_dock.on_sub_range_tts_requested(clip.id, sub_start, sub_end) + + expected_text = text[:sub_start] + new_fragment + text[sub_end:] + assert qt_app.document.text == expected_text + + new_end = sub_start + len(new_fragment) + new_clip = next( + c for c in qt_app.document.clips if qt_app.document.clip_extent(c.id) == (sub_start, new_end) + ) + assert new_clip.character_id == alice.id + + assert qt_app.editor.toPlainText() == expected_text + + +# --------------------------------------------------------------------------- +# Undo/redo. +# --------------------------------------------------------------------------- + +def test_both_pushes_are_undoable_and_undo_twice_restores_original_state(qt_app, monkeypatch): + text = "The quick brown fox jumps" + clip, alice = _make_whole_document_clip(qt_app, text) + original_clip_id = clip.id + + sub_start, sub_end = 4, 9 # "quick" + new_fragment = "slow" + monkeypatch.setattr(QDialog, "exec", _accept_dialog_with(new_fragment, alice.id)) + + qt_app.timeline_dock.on_sub_range_tts_requested(clip.id, sub_start, sub_end) + + assert qt_app.document.undo_stack.can_undo() is True + assert qt_app.document.text != text + + qt_app.undo() # undoes AssignCharacterCommand + qt_app.undo() # undoes TextEditCommand + + assert qt_app.document.text == text + assert len(qt_app.document.clips) == 1 + restored_clip = qt_app.document.clips[0] + assert restored_clip.id == original_clip_id + assert qt_app.document.clip_extent(restored_clip.id) == (0, len(text)) + + +# --------------------------------------------------------------------------- +# Cancel. +# --------------------------------------------------------------------------- + +def test_cancelling_dialog_pushes_nothing(qt_app, monkeypatch): + text = "The quick brown fox jumps" + clip, _alice = _make_whole_document_clip(qt_app, text) + can_undo_before = qt_app.document.undo_stack.can_undo() + clip_ids_before = {c.id for c in qt_app.document.clips} + + monkeypatch.setattr(QDialog, "exec", _reject_dialog) + + qt_app.timeline_dock.on_sub_range_tts_requested(clip.id, 4, 9) + + assert qt_app.document.undo_stack.can_undo() is can_undo_before + assert qt_app.document.text == text + assert {c.id for c in qt_app.document.clips} == clip_ids_before + assert qt_app.engine.generate_dirty_clips.called is False diff --git a/tests/gui_qt/test_timeline_batch_generate.py b/tests/gui_qt/test_timeline_batch_generate.py new file mode 100644 index 0000000..d8a46f1 --- /dev/null +++ b/tests/gui_qt/test_timeline_batch_generate.py @@ -0,0 +1,160 @@ +"""Tests for the consolidated action bar's batch dirty-scoped Generate path +(item 3, "Consolidated action bar + batch dirty-scoped generation" of the +DAW-for-text remaining-work roadmap): `QtTTSApp.on_generate_clicked` +dispatching to `TimelineDock.generate_dirty_clips_requested`, and that +dock's handling of a resolved `KokoroEngine.generate_dirty_clips` future. +Mirrors tests/gui_qt/test_timeline_clip_generate.py's conventions.""" +from kokoro_gui.daw.dirty import build_segments_from_results, compute_expected_cache_hash + + +def _make_clip(qt_app, start=0, end=5, text="hello world"): + qt_app.document.text = text + character = qt_app.document.characters[0] + return qt_app.document.assign_character_to_range(start, end, character.id) + + +def _make_two_dirty_clips(qt_app): + text = "First clip text here. Second clip text here." + qt_app.document.text = text + character = qt_app.document.characters[0] + split = text.index(" Second") + clip_a = qt_app.document.assign_character_to_range(0, split, character.id) + clip_b = qt_app.document.assign_character_to_range(split, len(text), character.id) + return clip_a, clip_b + + +def _mark_not_dirty(qt_app, clip, tmp_path): + """Populates `clip.segments` so `Document.dirty_clips()` no longer + reports it - a fake-but-consistent "already generated" state. The file + has to exist: a segment whose file is missing is dirty (grill TB11).""" + text = qt_app.document.clip_text(clip) + config = qt_app._assemble_clip_config(clip) + expected_hash = compute_expected_cache_hash(text, config) + path = tmp_path / "clip.wav" + path.write_bytes(b"RIFF") + fake_results = [{"text": text, "path": str(path), "duration": 1.0, "seg_idx": 0}] + clip.segments = build_segments_from_results(expected_hash, fake_results) + + +# -- on_generate_clicked dispatch -------------------------------------------- + +def test_on_generate_clicked_with_clips_present_and_dirty_calls_generate_dirty_clips(qt_app): + _make_clip(qt_app) + + qt_app.on_generate_clicked() + + assert qt_app.engine.generate_dirty_clips.called + assert not qt_app.engine.start_conversion.called + + +def test_on_generate_clicked_with_no_clips_calls_start_conversion(qt_app, monkeypatch): + assert qt_app.document.clips == [] + calls = [] + monkeypatch.setattr(qt_app, "start_conversion", lambda: calls.append(True)) + + qt_app.on_generate_clicked() + + assert calls == [True] + assert not qt_app.engine.generate_dirty_clips.called + + +def test_on_generate_clicked_with_clips_present_but_none_dirty_calls_neither(qt_app, monkeypatch, tmp_path): + clip = _make_clip(qt_app) + _mark_not_dirty(qt_app, clip, tmp_path) + assert qt_app.document.dirty_clips() == [] + + set_ui_state_calls = [] + monkeypatch.setattr(qt_app, "set_ui_state", lambda *a, **k: set_ui_state_calls.append(a)) + + qt_app.on_generate_clicked() + + assert not qt_app.engine.generate_dirty_clips.called + assert not qt_app.engine.start_conversion.called + assert set_ui_state_calls == [] + + +def test_generate_blocked_while_a_job_is_already_running(qt_app): + _make_clip(qt_app) + qt_app.transport_dock.set_busy(True) # simulate a running job + + qt_app.timeline_dock.generate_dirty_clips_requested() + + assert not qt_app.engine.generate_dirty_clips.called + + +# -- batch completion handling ------------------------------------------------ + +def test_partial_batch_completion_populates_segments_for_succeeded_only_and_reports_status(qt_app): + clip_a, clip_b = _make_two_dirty_clips(qt_app) + qt_app.timeline_dock.generate_dirty_clips_requested() + assert qt_app.engine.generate_dirty_clips.called + + text_a = qt_app.document.clip_text(clip_a) + config_a = qt_app._assemble_clip_config(clip_a) + expected_hash_a = compute_expected_cache_hash(text_a, config_a) + + outcomes = [ + { + "clip_id": clip_a.id, "success": True, + "results": [{"path": "a.wav", "text": text_a, "duration": 1.0, "seg_idx": 0}], + "error": "", "cancelled": False, + }, + { + "clip_id": clip_b.id, "success": False, + "results": [], "error": "boom", "cancelled": False, + }, + ] + qt_app.engine.worker.run_coro.return_value.set_result(outcomes) + + assert len(clip_a.segments) == 1 + assert clip_a.segments[0].audio_path == "a.wav" + assert clip_a.segments[0].cache_key == expected_hash_a + assert clip_b.segments == [] # failed clip's segments left untouched + + assert "Generated 1 of 2 clips (1 failed)" in qt_app.transport_dock.status_text() + assert "orange" in qt_app.transport_dock.progress_bar.styleSheet() + + +def test_total_batch_failure_uses_error_styling(qt_app): + clip_a, clip_b = _make_two_dirty_clips(qt_app) + qt_app.timeline_dock.generate_dirty_clips_requested() + + outcomes = [ + {"clip_id": clip_a.id, "success": False, "results": [], "error": "boom", "cancelled": False}, + {"clip_id": clip_b.id, "success": False, "results": [], "error": "boom", "cancelled": False}, + ] + qt_app.engine.worker.run_coro.return_value.set_result(outcomes) + + assert clip_a.segments == [] + assert clip_b.segments == [] + assert "#ff5555" in qt_app.transport_dock.progress_bar.styleSheet() + + +def test_fully_successful_batch_calls_schedule_save_and_refresh_timeline_once(qt_app, monkeypatch): + clip_a, clip_b = _make_two_dirty_clips(qt_app) + save_calls = [] + refresh_calls = [] + monkeypatch.setattr(qt_app, "schedule_save", lambda: save_calls.append(True)) + monkeypatch.setattr(qt_app, "refresh_timeline", lambda: refresh_calls.append(True)) + + qt_app.timeline_dock.generate_dirty_clips_requested() + + text_a = qt_app.document.clip_text(clip_a) + text_b = qt_app.document.clip_text(clip_b) + outcomes = [ + { + "clip_id": clip_a.id, "success": True, + "results": [{"path": "a.wav", "text": text_a, "duration": 1.0, "seg_idx": 0}], + "error": "", "cancelled": False, + }, + { + "clip_id": clip_b.id, "success": True, + "results": [{"path": "b.wav", "text": text_b, "duration": 1.0, "seg_idx": 0}], + "error": "", "cancelled": False, + }, + ] + qt_app.engine.worker.run_coro.return_value.set_result(outcomes) + + assert len(save_calls) == 1 + assert len(refresh_calls) == 1 + assert "Generated 2 clip(s)." in qt_app.transport_dock.status_text() diff --git a/tests/gui_qt/test_timeline_clip_fx.py b/tests/gui_qt/test_timeline_clip_fx.py new file mode 100644 index 0000000..1fa8d9f --- /dev/null +++ b/tests/gui_qt/test_timeline_clip_fx.py @@ -0,0 +1,149 @@ +"""Tests for per-clip FX (item 5, "Per-clip FX button"): choosing an FX +preset (or "Clear FX") from a timeline clip block's FX menu +(kokoro_gui/qt/timeline_view.py) via kokoro_gui/qt/docks/timeline_dock.py's +`on_fx_preset_requested`, and `_assemble_clip_config`'s (kokoro_gui/qt/app.py) +"clip-level FX always wins" merge - mirrors test_timeline_clip_generate.py's +conventions (qt_app fixture, StubEngine's mocked engine calls).""" +from kokoro_gui.daw.undo import SetClipFxCommand + + +def _make_clip(qt_app, start=0, end=5, text="hello world"): + qt_app.document.text = text + character = qt_app.document.characters[0] + return qt_app.document.assign_character_to_range(start, end, character.id) + + +# --------------------------------------------------------------------------- +# TimelineDock.on_fx_preset_requested +# --------------------------------------------------------------------------- + +def test_choosing_a_preset_sets_clip_fx_override_via_undoable_command(qt_app): + clip = _make_clip(qt_app) + qt_app.engine.load_fx_preset.return_value = { + "reverb_enabled": True, "reverb_room_size": 0.5, "not_an_fx_key": "dropped", + } + + qt_app.timeline_dock.on_fx_preset_requested(clip.id, "Warm") + + assert clip.fx_override == {"reverb_enabled": True, "reverb_room_size": 0.5} + assert qt_app.document.undo_stack.can_undo() is True + + +def test_choosing_a_preset_calls_load_fx_preset_with_the_chosen_name(qt_app): + clip = _make_clip(qt_app) + qt_app.engine.load_fx_preset.return_value = {"reverb_enabled": True} + + qt_app.timeline_dock.on_fx_preset_requested(clip.id, "Telephone") + + # Once to resolve the override's values, once more when the FX tab + # re-renders for the (now selected) clip. + qt_app.engine.load_fx_preset.assert_any_call("Telephone", qt_app.project_dir) + + +def test_clear_fx_on_a_clip_with_an_override_sets_it_back_to_none(qt_app): + clip = _make_clip(qt_app) + clip.fx_override = {"reverb_enabled": True} + + qt_app.timeline_dock.on_fx_preset_requested(clip.id, "") + + assert clip.fx_override is None + assert qt_app.document.undo_stack.can_undo() is True + + +def test_clear_fx_pushes_undoable_command_restoring_previous_override(qt_app): + clip = _make_clip(qt_app) + clip.fx_override = {"reverb_enabled": True} + + qt_app.timeline_dock.on_fx_preset_requested(clip.id, "") + assert clip.fx_override is None + + qt_app.document.undo_stack.undo() + + assert clip.fx_override == {"reverb_enabled": True} + + +def test_choosing_a_preset_that_fails_to_load_clears_the_override(qt_app): + clip = _make_clip(qt_app) + qt_app.engine.load_fx_preset.return_value = None # missing/unreadable preset file + + qt_app.timeline_dock.on_fx_preset_requested(clip.id, "GoneNow") + + assert clip.fx_override is None + + +def test_unknown_clip_id_is_a_noop(qt_app): + qt_app.timeline_dock.on_fx_preset_requested("nonexistent-clip-id", "Warm") + + assert qt_app.document.undo_stack.can_undo() is False + + +def test_on_fx_preset_requested_calls_refresh_timeline_and_schedule_save(qt_app, monkeypatch): + clip = _make_clip(qt_app) + qt_app.engine.load_fx_preset.return_value = {"reverb_enabled": True} + refresh_calls = [] + save_calls = [] + monkeypatch.setattr(qt_app, "refresh_timeline", lambda: refresh_calls.append(True)) + monkeypatch.setattr(qt_app, "schedule_save", lambda: save_calls.append(True)) + + qt_app.timeline_dock.on_fx_preset_requested(clip.id, "Warm") + + assert refresh_calls + assert save_calls + + +def test_fx_preset_requested_signal_is_connected_to_the_dock_handler(qt_app): + clip = _make_clip(qt_app) + qt_app.engine.load_fx_preset.return_value = {"reverb_enabled": True} + + qt_app.timeline_dock.timeline_view.fxPresetRequested.emit(clip.id, "Warm") + + assert clip.fx_override == {"reverb_enabled": True} + + +# --------------------------------------------------------------------------- +# _assemble_clip_config: clip-level fx_override wins over the character's +# resolved fx_preset for the same key(s). +# --------------------------------------------------------------------------- + +def test_assemble_clip_config_includes_fx_override_values(qt_app): + clip = _make_clip(qt_app) + clip.fx_override = {"reverb_enabled": True, "reverb_room_size": 0.8} + + config = qt_app._assemble_clip_config(clip) + + assert config["reverb_enabled"] is True + assert config["reverb_room_size"] == 0.8 + + +def test_assemble_clip_config_fx_override_wins_over_character_fx_preset(qt_app, monkeypatch): + clip = _make_clip(qt_app) + character = qt_app.document.characters[0] + character.preset_data["apply_fx"] = True + character.preset_data["fx_preset"] = "CharacterPreset" + + def _fake_load_fx_preset(name, project_dir=None): + assert name == "CharacterPreset" + return {"reverb_enabled": True, "reverb_room_size": 0.2} + + monkeypatch.setattr(qt_app.engine, "load_fx_preset", _fake_load_fx_preset) + clip.fx_override = {"reverb_room_size": 0.9} # conflicts with the character preset's value + + config = qt_app._assemble_clip_config(clip) + + # The character preset's other key still applies... + assert config["reverb_enabled"] is True + # ...but the clip's own override wins on the conflicting key. + assert config["reverb_room_size"] == 0.9 + + +def test_assemble_clip_config_with_no_fx_override_carries_the_project_fx(qt_app): + """A clip with no preset and no override plays the Audio FX tab's + project values (the bottom layer of fx_resolve.resolve_fx).""" + clip = _make_clip(qt_app) + assert clip.fx_override is None + qt_app.fx_dock._value_widgets["gain_db"].setValue(4.0) + + config = qt_app._assemble_clip_config(clip) + + assert config["gain_db"] == 4.0 + assert config["reverb_enabled"] == qt_app.fx_dock.project_fx_state()["reverb_enabled"] diff --git a/tests/gui_qt/test_timeline_clip_generate.py b/tests/gui_qt/test_timeline_clip_generate.py new file mode 100644 index 0000000..a39afb0 --- /dev/null +++ b/tests/gui_qt/test_timeline_clip_generate.py @@ -0,0 +1,167 @@ +"""Tests for per-clip Generate: the right-click "Generate" action on a +timeline clip block (kokoro_gui/qt/timeline_view.py's context menu + +kokoro_gui/qt/docks/timeline_dock.py's dispatch/completion handling).""" +from kokoro_gui.daw.dirty import compute_expected_cache_hash + + +def _make_clip(qt_app, start=0, end=5, text="hello world"): + qt_app.document.text = text + character = qt_app.document.characters[0] + return qt_app.document.assign_character_to_range(start, end, character.id) + + +def _clip_block_for(qt_app, clip_id): + from kokoro_gui.qt.timeline_view import ClipBlockItem + for item in qt_app.timeline_dock.timeline_view._scene.items(): + if isinstance(item, ClipBlockItem) and item.clip_id == clip_id: + return item + return None + + +def test_context_menu_shows_generate_action_over_a_clip_block(qt_app): + clip = _make_clip(qt_app) + qt_app.refresh_timeline() + block = _clip_block_for(qt_app, clip.id) + pos = block.mapToScene(0, 0) + view_pos = qt_app.timeline_dock.timeline_view.mapFromScene(pos) + + menu = qt_app.timeline_dock.timeline_view._build_context_menu(view_pos) + + assert menu is not None + assert [a.text() for a in menu.actions()] == ["Generate"] + + +def test_context_menu_empty_over_lane_background(qt_app): + _make_clip(qt_app, start=0, end=5) + qt_app.refresh_timeline() + + # Far outside any clip block's geometry. + menu = qt_app.timeline_dock.timeline_view._build_context_menu(qt_app.timeline_dock.timeline_view.mapFromScene(9999, 9999)) + + assert menu is None + + +def test_generate_clip_requested_signal_carries_clip_id(qt_app): + clip = _make_clip(qt_app) + qt_app.refresh_timeline() + block = _clip_block_for(qt_app, clip.id) + pos = block.mapToScene(0, 0) + view_pos = qt_app.timeline_dock.timeline_view.mapFromScene(pos) + + menu = qt_app.timeline_dock.timeline_view._build_context_menu(view_pos) + received = [] + qt_app.timeline_dock.timeline_view.generateClipRequested.connect(lambda cid: received.append(cid)) + menu.actions()[0].trigger() + + assert received == [clip.id] + + +def test_on_generate_clip_requested_calls_engine_with_assembled_config(qt_app): + clip = _make_clip(qt_app) + character = qt_app.document.characters[0] + character.preset_data["voice"] = "af_sarah" + + qt_app.timeline_dock.on_generate_clip_requested(clip.id) + + assert qt_app.engine.generate_clip_audio.called + (chunk_data,), _kwargs = qt_app.engine.generate_clip_audio.call_args + index, text, config = chunk_data + assert text == qt_app.document.clip_text(clip) + assert config["voice"] == "af_sarah" + + +def test_successful_generation_populates_segments_with_order_index_and_shared_cache_key(qt_app): + clip = _make_clip(qt_app) + qt_app.timeline_dock.on_generate_clip_requested(clip.id) + + results = [ + {"path": "a.wav", "text": "hello", "duration": 1.0, "seg_idx": 0}, + {"path": "b.wav", "text": "world", "duration": 1.0, "seg_idx": 0}, + ] + qt_app.engine.worker.run_coro.return_value.set_result(results) + + assert [s.order_index for s in clip.segments] == [0, 1] + assert clip.segments[0].cache_key == clip.segments[1].cache_key + assert clip.segments[0].audio_path == "a.wav" + assert clip.segments[1].audio_path == "b.wav" + + +def test_successful_generation_calls_refresh_timeline_and_schedule_save(qt_app, monkeypatch): + clip = _make_clip(qt_app) + refresh_calls = [] + save_calls = [] + monkeypatch.setattr(qt_app, "refresh_timeline", lambda: refresh_calls.append(True)) + monkeypatch.setattr(qt_app, "schedule_save", lambda: save_calls.append(True)) + + qt_app.timeline_dock.on_generate_clip_requested(clip.id) + qt_app.engine.worker.run_coro.return_value.set_result( + [{"path": "a.wav", "text": "hello world", "duration": 1.0, "seg_idx": 0}] + ) + + assert refresh_calls + assert save_calls + + +def test_failed_generation_with_exception_leaves_segments_unchanged(qt_app): + clip = _make_clip(qt_app) + qt_app.timeline_dock.on_generate_clip_requested(clip.id) + + qt_app.engine.worker.run_coro.return_value.set_exception(RuntimeError("boom")) + + assert clip.segments == [] + assert "Clip generation failed" in qt_app.transport_dock.status_text() + + +def test_failed_generation_with_empty_result_leaves_segments_unchanged(qt_app): + clip = _make_clip(qt_app) + qt_app.timeline_dock.on_generate_clip_requested(clip.id) + + qt_app.engine.worker.run_coro.return_value.set_result([]) + + assert clip.segments == [] + assert "Clip generation failed" in qt_app.transport_dock.status_text() + + +def test_generate_blocked_while_a_job_is_already_running(qt_app): + clip = _make_clip(qt_app) + qt_app.transport_dock.set_busy(True) # simulate a running whole-document job + + qt_app.timeline_dock.on_generate_clip_requested(clip.id) + + assert not qt_app.engine.generate_clip_audio.called + + +# --- takes (Claude/old/PLAN_tbaw_bundle.md 2.3, grill TB8) ----------------------- + +def test_generate_clip_on_a_clean_clip_sends_regenerate_and_a_dirty_one_does_not(qt_app, tmp_path): + from kokoro_gui.daw.dirty import build_segments_from_results + + clip = _make_clip(qt_app) + qt_app.generate_clip(clip.id) # dirty: never generated + (chunk_data,), _ = qt_app.engine.generate_clip_audio.call_args + assert "regenerate" not in chunk_data[2] + qt_app.transport_dock.set_busy(False) + + path = tmp_path / "seg.wav" + path.write_bytes(b"RIFF") + text = qt_app.document.clip_text(clip) + key = qt_app.document.segment_key_fn(text, clip) + clip.segments = build_segments_from_results(key, [{"text": text, "path": str(path), "duration": 1.0}]) + assert qt_app.document.dirty_clips() == [] + + qt_app.generate_clip(clip.id) # clean: the gutter button means regenerate + (chunk_data,), _ = qt_app.engine.generate_clip_audio.call_args + assert chunk_data[2]["regenerate"] is True + + +def test_results_stamp_the_take_key_and_version_the_engine_reports(qt_app): + clip = _make_clip(qt_app) + qt_app.timeline_dock.on_generate_clip_requested(clip.id) + qt_app.engine.worker.run_coro.return_value.set_result([ + {"path": "a.wav", "text": "hello world", "duration": 1.0, "seg_idx": 0, + "cache_key": "k-take-2", "take": 2, "engine_version": "9.9"}, + ]) + assert clip.overrides["take"] == 2 + assert clip.segments[0].cache_key == "k-take-2" + assert clip.segments[0].engine_version == "9.9" + assert qt_app._assemble_generation_config(clip)["take"] == 2 diff --git a/tests/gui_qt/test_timeline_drag_reassign.py b/tests/gui_qt/test_timeline_drag_reassign.py new file mode 100644 index 0000000..78c5a97 --- /dev/null +++ b/tests/gui_qt/test_timeline_drag_reassign.py @@ -0,0 +1,201 @@ +"""Tests for item 8 ("Drag-to-reassign a clip to a different track"): +dragging a clip block onto a different track's lane, Q9's reassign-vs-move +prompt when the target lane's character differs from the clip's own, and +`TimelineDock.on_clip_drag_reassigned` re-resolving ids off +`TimelineView.clipDragReassigned` and pushing the resulting +`MoveClipCommand`/`ReassignTrackCommand` (kokoro_gui/daw/undo.py) - mirrors +test_timeline_clip_fx.py's conventions (qt_app fixture, driving the real +dock/view rather than only unit-level document mutation). +""" +from PySide6.QtCore import QPointF, Qt +from PySide6.QtWidgets import QMessageBox + +from kokoro_gui.daw.models import Character, Track +from kokoro_gui.qt.timeline_view import LANE_HEIGHT_PX, lane_top + + +def _setup_two_tracks(qt_app, same_character: bool): + """Replaces qt_app.document's tracks with two lanes ordered [0, 1] - + track_a (the clip's starting lane) and track_b (the drop target). + `same_character=False` gives track_b a distinct Character, engaging + Q9's reassign-vs-move prompt on drop.""" + document = qt_app.document + alice = document.characters[0] + if same_character: + bob = alice + else: + bob = Character.from_preset_dict("Bob", {}) + document.characters.append(bob) + + track_a = Track(name="Track A", character_id=alice.id, order_index=0) + track_b = Track(name="Track B", character_id=bob.id, order_index=1) + document.tracks = [track_a, track_b] + return track_a, track_b, alice, bob + + +def _make_clip_on_track(qt_app, track, start=0, end=5, text="hello world"): + document = qt_app.document + document.text = text + clip = document.assign_character_to_range(start, end, track.character_id) + clip.track_id = track.id + return clip + + +def _drag_clip_onto_track_b(qt_app, qtbot, clip): + """Simulates the full mouse gesture: press on the clip's block, release + over track_b's lane (lane index 1, below the ruler).""" + qt_app.timeline_dock.refresh() + view = qt_app.timeline_dock.timeline_view + block = view._blocks_by_clip_id[clip.id] + + press_pos = view.mapFromScene(block.mapToScene(2, 2)) + release_scene_x = block.mapToScene(2, 2).x() + release_pos = view.mapFromScene(QPointF(release_scene_x, lane_top(1) + 10)) + + qtbot.mousePress(view.viewport(), Qt.MouseButton.LeftButton, pos=press_pos) + qtbot.mouseRelease(view.viewport(), Qt.MouseButton.LeftButton, pos=release_pos) + + +def _clicked_button_named(button_text): + """A QMessageBox.clickedButton replacement that always resolves to the + button whose text matches `button_text`, regardless of which QMessageBox + instance called it - used with QMessageBox.exec patched to a no-op, so + tests can drive each of the three prompt outcomes (Reassign/Just Move/ + Cancel) without a real, blocking modal dialog.""" + def _clicked_button(self): + return next(b for b in self.buttons() if b.text() == button_text) + return _clicked_button + + +# --------------------------------------------------------------------------- +# No ambiguity: target lane's character already matches - straight move, +# no prompt. +# --------------------------------------------------------------------------- + +def test_drag_to_track_with_matching_character_moves_without_prompt(qt_app, qtbot, monkeypatch): + track_a, track_b, alice, _bob = _setup_two_tracks(qt_app, same_character=True) + clip = _make_clip_on_track(qt_app, track_a) + + def _fail_exec(self): + raise AssertionError("no prompt expected when the target character already matches") + monkeypatch.setattr(QMessageBox, "exec", _fail_exec) + + _drag_clip_onto_track_b(qt_app, qtbot, clip) + + assert clip.track_id == track_b.id + assert clip.character_id == alice.id + assert qt_app.document.undo_stack.can_undo() is True + + qt_app.document.undo_stack.undo() + assert clip.track_id == track_a.id + + +# --------------------------------------------------------------------------- +# Ambiguity: target lane's character differs - the three-way prompt. +# --------------------------------------------------------------------------- + +def test_drag_reassign_choice_updates_track_and_character(qt_app, qtbot, monkeypatch): + track_a, track_b, alice, bob = _setup_two_tracks(qt_app, same_character=False) + clip = _make_clip_on_track(qt_app, track_a) + + monkeypatch.setattr(QMessageBox, "exec", lambda self: 0) + monkeypatch.setattr(QMessageBox, "clickedButton", _clicked_button_named("Reassign")) + + _drag_clip_onto_track_b(qt_app, qtbot, clip) + + assert clip.track_id == track_b.id + assert clip.character_id == bob.id + assert qt_app.document.undo_stack.can_undo() is True + + qt_app.document.undo_stack.undo() + assert clip.track_id == track_a.id + assert clip.character_id == alice.id + + +def test_drag_just_move_choice_updates_track_only(qt_app, qtbot, monkeypatch): + track_a, track_b, alice, _bob = _setup_two_tracks(qt_app, same_character=False) + clip = _make_clip_on_track(qt_app, track_a) + + monkeypatch.setattr(QMessageBox, "exec", lambda self: 0) + monkeypatch.setattr(QMessageBox, "clickedButton", _clicked_button_named("Just Move")) + + _drag_clip_onto_track_b(qt_app, qtbot, clip) + + assert clip.track_id == track_b.id + assert clip.character_id == alice.id # unchanged + assert qt_app.document.undo_stack.can_undo() is True + + qt_app.document.undo_stack.undo() + assert clip.track_id == track_a.id + assert clip.character_id == alice.id + + +def test_drag_cancel_choice_pushes_nothing(qt_app, qtbot, monkeypatch): + track_a, _track_b, alice, _bob = _setup_two_tracks(qt_app, same_character=False) + clip = _make_clip_on_track(qt_app, track_a) + can_undo_before = qt_app.document.undo_stack.can_undo() + + monkeypatch.setattr(QMessageBox, "exec", lambda self: 0) + monkeypatch.setattr(QMessageBox, "clickedButton", _clicked_button_named("Cancel")) + + _drag_clip_onto_track_b(qt_app, qtbot, clip) + + assert clip.track_id == track_a.id + assert clip.character_id == alice.id + assert qt_app.document.undo_stack.can_undo() is can_undo_before + + +# --------------------------------------------------------------------------- +# Busy/no-op cases leave the undo stack untouched. +# --------------------------------------------------------------------------- + +def test_drag_along_same_track_pins_a_timestamp_and_keeps_the_track(qt_app, qtbot): + """UI9: a horizontal drag on the same lane is a move on the seconds + axis (an undoable SetClipTimestampCommand), never a track change.""" + track_a, _track_b, _alice, _bob = _setup_two_tracks(qt_app, same_character=True) + clip = _make_clip_on_track(qt_app, track_a) + assert clip.timeline_timestamp is None + + qt_app.timeline_dock.refresh() + view = qt_app.timeline_dock.timeline_view + block = view._blocks_by_clip_id[clip.id] + press_pos = view.mapFromScene(block.mapToScene(2, 2)) + release_pos = view.mapFromScene(block.mapToScene(60, 2)) # same lane, big x movement + + qtbot.mousePress(view.viewport(), Qt.MouseButton.LeftButton, pos=press_pos) + qtbot.mouseRelease(view.viewport(), Qt.MouseButton.LeftButton, pos=release_pos) + + assert clip.track_id == track_a.id + assert clip.timeline_timestamp is not None + assert clip.timeline_timestamp > 0 + qt_app.undo() + assert clip.timeline_timestamp is None + + +def test_drag_off_all_lanes_pushes_no_command(qt_app, qtbot): + track_a, _track_b, _alice, _bob = _setup_two_tracks(qt_app, same_character=True) + clip = _make_clip_on_track(qt_app, track_a) + can_undo_before = qt_app.document.undo_stack.can_undo() + + qt_app.timeline_dock.refresh() + view = qt_app.timeline_dock.timeline_view + block = view._blocks_by_clip_id[clip.id] + press_pos = view.mapFromScene(block.mapToScene(2, 2)) + release_scene_x = block.mapToScene(2, 2).x() + release_pos = view.mapFromScene(QPointF(release_scene_x, LANE_HEIGHT_PX * 100)) + + qtbot.mousePress(view.viewport(), Qt.MouseButton.LeftButton, pos=press_pos) + qtbot.mouseRelease(view.viewport(), Qt.MouseButton.LeftButton, pos=release_pos) # must not crash + + assert clip.track_id == track_a.id + assert qt_app.document.undo_stack.can_undo() is can_undo_before + + +def test_clip_drag_reassigned_signal_is_connected_to_the_dock_handler(qt_app): + track_a, track_b, alice, _bob = _setup_two_tracks(qt_app, same_character=True) + clip = _make_clip_on_track(qt_app, track_a) + + qt_app.timeline_dock.timeline_view.clipDragReassigned.emit(clip.id, track_b.id, False) + + assert clip.track_id == track_b.id + assert clip.character_id == alice.id diff --git a/tests/gui_qt/test_timeline_move.py b/tests/gui_qt/test_timeline_move.py new file mode 100644 index 0000000..951fa6b --- /dev/null +++ b/tests/gui_qt/test_timeline_move.py @@ -0,0 +1,81 @@ +"""Tests for TimelineDock.on_clip_moved / on_clip_unpin_requested (UI9 and +grill Q13): a horizontal drop pins a timestamp, and a drop before the +text-order predecessor also moves the clip's text.""" +from PySide6.QtGui import QTextCursor + +from kokoro_gui.daw.arrangement import compute_arrangement +from kokoro_gui.daw.models import Character + + +def _type(editor, text): + cursor = editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(text) + + +def _two_clips(qt_app): + bob = Character.from_preset_dict("Bob", {}) + qt_app.document.characters.append(bob) + from kokoro_gui.daw.models import Track + + qt_app.document.tracks.append(Track(name="Bob", character_id=bob.id, order_index=1)) + _type(qt_app.editor, "aaaaaaaaaa bbbbbbbbbb") + alice = qt_app.document.characters[0] + a = qt_app.document.assign_character_to_range(0, 10, alice.id) + b = qt_app.document.assign_character_to_range(11, 21, bob.id) + qt_app.editor.rehighlight() + qt_app.refresh_timeline() + return a, b + + +def test_drop_after_the_predecessor_only_pins_a_timestamp(qt_app): + a, b = _two_clips(qt_app) + text_before = qt_app.document.text + + qt_app.timeline_dock.on_clip_moved(b.id, 9.0) + + assert qt_app.document.text == text_before + assert b.timeline_timestamp == 9.0 + assert compute_arrangement(qt_app.document, chars_per_second=10.0).placed[1].start_s == 9.0 + qt_app.undo() + assert b.timeline_timestamp is None + + +def test_drop_before_the_predecessor_moves_the_text_too(qt_app): + a, b = _two_clips(qt_app) + + qt_app.timeline_dock.on_clip_moved(b.id, 0.0) # a starts at 0.0 - b now lands before it + + assert qt_app.document.text == "bbbbbbbbbbaaaaaaaaaa " + assert qt_app.editor.toPlainText() == qt_app.document.text + assert b.timeline_timestamp == 0.0 + placed = compute_arrangement(qt_app.document, chars_per_second=10.0).placed + assert [p.clip.id for p in placed] == [b.id, a.id] + + qt_app.undo() + assert qt_app.document.text == "aaaaaaaaaa bbbbbbbbbb" + # The command restores a snapshot, so re-fetch the clip by id. + assert qt_app.document.get_clip(b.id).timeline_timestamp is None + + +def test_unpin_clears_the_timestamp(qt_app): + a, _b = _two_clips(qt_app) + qt_app.timeline_dock.on_clip_moved(a.id, 5.0) + assert a.timeline_timestamp == 5.0 + + qt_app.timeline_dock.on_clip_unpin_requested(a.id) + + assert a.timeline_timestamp is None + + +def test_timeline_context_menu_offers_unpin_only_for_pinned_clips(qt_app): + a, _b = _two_clips(qt_app) + view = qt_app.timeline_dock.timeline_view + block = view._blocks_by_clip_id[a.id] + pos = view.mapFromScene(block.mapToScene(2, 2)) + assert "Unpin from timeline" not in [x.text() for x in view._build_context_menu(pos).actions()] + + qt_app.timeline_dock.on_clip_moved(a.id, 5.0) + block = view._blocks_by_clip_id[a.id] + pos = view.mapFromScene(block.mapToScene(2, 2)) + assert "Unpin from timeline" in [x.text() for x in view._build_context_menu(pos).actions()] diff --git a/tests/gui_qt/test_timeline_selection_sync.py b/tests/gui_qt/test_timeline_selection_sync.py new file mode 100644 index 0000000..7d657b1 --- /dev/null +++ b/tests/gui_qt/test_timeline_selection_sync.py @@ -0,0 +1,119 @@ +"""Tests for kokoro_gui/qt/timeline_view.py's click-to-select wiring (item 1, +"Sync layer") - standalone TimelineView + a bare SelectionModel, no full +qt_app, mirroring test_timeline_view.py's app-independence pattern.""" +from PySide6.QtCore import QPoint, Qt + +from kokoro_gui.daw.models import Character, Clip, Document, Run, Track +from kokoro_gui.qt.selection import SelectionModel +from kokoro_gui.qt.timeline_view import ClipBlockItem, TimelineView + + +def _clip_block_items(view): + return [item for item in view._scene.items() if isinstance(item, ClipBlockItem)] + + +def _click(view, pos: QPoint, qtbot): + qtbot.mouseClick(view.viewport(), Qt.MouseButton.LeftButton, pos=pos) + + +def _build_doc_with_one_clip(): + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + clip = Clip(character_id=alice.id, track_id=track.id) + doc = Document( + runs=[Run(text="x" * 10, clip_id=clip.id, kind=clip.source), Run(text="x" * 30)], + characters=[alice], tracks=[track], clips=[clip], + ) + return doc, clip, track + + +def test_clicking_clip_block_selects_that_clip(qtbot): + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, _track = _build_doc_with_one_clip() + view.render_document(doc) + + block = _clip_block_items(view)[0] + pos = view.mapFromScene(block.mapToScene(5, 5)) + _click(view, pos, qtbot) + + assert selection.selected_clip_id == clip.id + assert selection.kind == "clip" + + +def test_clicking_empty_lane_space_clears_selection(qtbot): + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, _track = _build_doc_with_one_clip() + view.render_document(doc) + selection.select_clip(clip.id) + + # Far past the clip's end, still inside the lane rect - empty space. + pos = view.mapFromScene(500, 40) + _click(view, pos, qtbot) + + assert selection.kind == "none" + + +def test_clicking_lane_label_selects_character(qtbot): + """Track labels live in the header column (TimelineWidget.header), a + separate view so labels never overlap clips.""" + from kokoro_gui.qt.timeline_view import TimelineWidget, lane_top + + selection = SelectionModel() + widget = TimelineWidget(selection_model=selection) + qtbot.addWidget(widget) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + doc = Document.from_plain_text("x" * 40, characters=[alice], tracks=[track], clips=[]) + widget.render_document(doc) + + # The label is drawn at (22, lane_top + 5); click inside its glyph area. + header = widget.header + pos = header.mapFromScene(30, lane_top(0) + 10) + _click(header, pos, qtbot) + + assert selection.kind == "character" + assert selection.selected_character_id == alice.id + + +def test_set_selected_flips_internal_attribute(): + block = ClipBlockItem() + assert block._selected is False + block.set_selected(True) + assert block._selected is True + block.set_selected(False) + assert block._selected is False + + +def test_selection_survives_unrelated_rerender(qtbot): + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, _track = _build_doc_with_one_clip() + view.render_document(doc) + selection.select_clip(clip.id) + + # Simulate an unrelated refresh (e.g. a keystroke elsewhere). + view.render_document(doc) + + block = _clip_block_items(view)[0] + assert block.clip_id == clip.id + assert block._selected is True + + +def test_selection_of_removed_clip_does_not_crash_and_clears_highlight(qtbot): + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, _track = _build_doc_with_one_clip() + view.render_document(doc) + selection.select_clip(clip.id) + + doc.clips = [] + view.render_document(doc) # must not raise + + assert _clip_block_items(view) == [] + assert view._selected_block is None diff --git a/tests/gui_qt/test_timeline_view.py b/tests/gui_qt/test_timeline_view.py new file mode 100644 index 0000000..43cd363 --- /dev/null +++ b/tests/gui_qt/test_timeline_view.py @@ -0,0 +1,905 @@ +"""Tests for kokoro_gui/qt/timeline_view.py's TimelineView/ClipBlockItem - +qtbot-only, no full qt_app (QtTTSApp) fixture, mirroring +test_waveform_view.py's app-independence, since this widget has no +dependency on the running app - it only needs a kokoro_gui.daw.models.Document.""" +import numpy as np +import soundfile as sf +from PySide6.QtCore import QPointF, Qt +from PySide6.QtWidgets import QMessageBox + +from kokoro_gui.daw.models import Character, Clip, Document, Run, Segment, Track +from kokoro_gui.qt.selection import SelectionModel +from kokoro_gui.daw.arrangement import compute_arrangement +from kokoro_gui.qt.timeline_view import ( + ClipBlockItem, DEFAULT_PIXELS_PER_SECOND, FX_BUTTON_HEIGHT_PX, FX_BUTTON_WIDTH_PX, LANE_HEIGHT_PX, + MIN_CLIP_WIDTH_PX, RULER_HEIGHT_PX, TimelineView, TimelineWidget, lane_top, seconds_to_x, +) + +# Every ungenerated clip is estimated at this rate, so a clip's width is +# predictable without generation_stats.json (see kokoro_gui/daw/arrangement.py). +CPS = 10.0 + + +def _render(view, doc): + view.render_document(doc, compute_arrangement(doc, chars_per_second=CPS)) + + +def _write_tone_wav(path, sample_rate=8000, seconds=0.25, freq=440): + t = np.linspace(0, seconds, int(sample_rate * seconds), endpoint=False) + data = (0.5 * np.sin(2 * np.pi * freq * t)).astype(np.float32) + sf.write(str(path), data, sample_rate) + + +def _clip_block_items(view): + return [item for item in view._scene.items() if isinstance(item, ClipBlockItem)] + + +def _tagged_doc(text, tagged=(), **kwargs): + """Builds a Document whose clips are placed at specific text offsets - + a test-only convenience, since Document has no offsets to set directly + any more (Claude/PLAN_text_editor_redesign.md's run-list rework). Pass + `tagged` as `[(start, end, clip), ...]`.""" + runs = [] + cursor = 0 + for start, end, clip in sorted(tagged, key=lambda t: t[0]): + if start > cursor: + runs.append(Run(text=text[cursor:start])) + runs.append(Run(text=text[start:end], clip_id=clip.id, kind=clip.source)) + cursor = end + if cursor < len(text): + runs.append(Run(text=text[cursor:])) + clips = kwargs.pop("clips", None) + if clips is None: + clips = [clip for _start, _end, clip in tagged] + return Document(runs=runs, clips=clips, **kwargs) + + +def test_lanes_match_track_count_and_order_index_ordering(qtbot): + widget = TimelineWidget() + qtbot.addWidget(widget) + alice = Character.from_preset_dict("Alice", {}) + bob = Character.from_preset_dict("Bob", {}) + # Deliberately built out of order_index order to prove sorting, not + # insertion order, drives lane position. + track_bob = Track(name="Bob", character_id=bob.id, order_index=1) + track_alice = Track(name="Alice", character_id=alice.id, order_index=0) + doc = Document.from_plain_text("", characters=[alice, bob], tracks=[track_bob, track_alice]) + + widget.render_document(doc) + + labels = [item for item in widget.header._scene.items() if hasattr(item, "text") and item.text() in ("Alice", "Bob")] + label_by_text = {label.text(): label for label in labels} + assert label_by_text["Alice"].pos().y() < label_by_text["Bob"].pos().y() + assert label_by_text["Alice"].pos().y() >= RULER_HEIGHT_PX + + +def test_first_clip_starts_at_zero_seconds_and_is_as_wide_as_its_estimate(qtbot): + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + clip = Clip(character_id=alice.id, track_id=track.id) + doc = _tagged_doc("x" * 40, [(10, 30, clip)], characters=[alice], tracks=[track]) + + _render(view, doc) + + blocks = _clip_block_items(view) + assert len(blocks) == 1 + block = blocks[0] + # Text offset 10 does not matter any more: the first clip in text order + # starts at 0s. 20 chars at CPS=10 -> 2.0s estimated. + assert block.pos().x() == 0 + assert block.estimated is True + assert block.boundingRect().width() == seconds_to_x(2.0, DEFAULT_PIXELS_PER_SECOND) + assert block.pos().y() == lane_top(0) + 8 + + +def test_clips_are_laid_end_to_end_in_text_order_across_tracks(qtbot): + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + bob = Character.from_preset_dict("Bob", {}) + track_a = Track(name="Alice", character_id=alice.id, order_index=0) + track_b = Track(name="Bob", character_id=bob.id, order_index=1) + clip_a = Clip(character_id=alice.id, track_id=track_a.id) + clip_b = Clip(character_id=bob.id, track_id=track_b.id) + doc = _tagged_doc("x" * 40, [(0, 10, clip_a), (10, 40, clip_b)], characters=[alice, bob], tracks=[track_a, track_b]) + + _render(view, doc) + + by_id = view._blocks_by_clip_id + assert by_id[clip_a.id].pos().x() == 0 + # clip_a is 10 chars / 10 cps = 1.0s, so clip_b starts at 1.0s on its own lane. + assert by_id[clip_b.id].pos().x() == seconds_to_x(1.0, DEFAULT_PIXELS_PER_SECOND) + assert by_id[clip_b.id].pos().y() == lane_top(1) + 8 + + +def test_pinned_timestamp_positions_the_clip(qtbot): + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + clip = Clip(character_id=alice.id, track_id=track.id, timeline_timestamp=3.5) + doc = _tagged_doc("x" * 10, [(0, 10, clip)], characters=[alice], tracks=[track]) + + _render(view, doc) + + assert _clip_block_items(view)[0].pos().x() == seconds_to_x(3.5, DEFAULT_PIXELS_PER_SECOND) + + +def test_zoom_rescales_clip_positions(qtbot): + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + clip = Clip(character_id=alice.id, track_id=track.id, timeline_timestamp=2.0) + doc = _tagged_doc("x" * 10, [(0, 10, clip)], characters=[alice], tracks=[track]) + _render(view, doc) + + view.set_zoom(100.0) + + assert view.zoom == 100.0 + assert _clip_block_items(view)[0].pos().x() == 200.0 + view.set_zoom(5.0) # clamps to the minimum + assert view.zoom == 20.0 + + +def test_clip_width_floors_at_min_clip_width(qtbot): + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + clip = Clip(character_id=alice.id, track_id=track.id) # 1 char, tiny + doc = _tagged_doc("x", [(0, 1, clip)], characters=[alice], tracks=[track]) + + _render(view, doc) + + block = _clip_block_items(view)[0] + assert block.boundingRect().width() == MIN_CLIP_WIDTH_PX + + +def test_clip_color_matches_character_highlight_color(qtbot): + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}, highlight_color="#abcdef") + track = Track(name="Alice", character_id=alice.id) + clip = Clip(character_id=alice.id, track_id=track.id) + doc = _tagged_doc("hello", [(0, 5, clip)], characters=[alice], tracks=[track]) + + _render(view, doc) + + block = _clip_block_items(view)[0] + assert block._color == "#abcdef" + + +def test_clip_with_unresolvable_character_uses_fallback_color(qtbot): + view = TimelineView() + qtbot.addWidget(view) + track = Track(name="Orphan") + clip = Clip(character_id="nonexistent", track_id=track.id) + doc = _tagged_doc("hello", [(0, 5, clip)], characters=[], tracks=[track]) + + _render(view, doc) + + block = _clip_block_items(view)[0] + assert block._color == "#888888" + + +def test_clip_with_unresolvable_track_is_skipped_not_crashed(qtbot): + view = TimelineView() + qtbot.addWidget(view) + clip = Clip(track_id="nonexistent") + doc = _tagged_doc("hello", [(0, 5, clip)], tracks=[]) + + _render(view, doc) # must not raise + + assert _clip_block_items(view) == [] + + +def test_bounding_rect_matches_set_geometry(): + block = ClipBlockItem() + block.set_geometry(5, 10, 100, 50) + rect = block.boundingRect() + assert rect.width() == 100 + assert rect.height() == 50 + + +def test_rerender_replaces_previous_clip_items(qtbot): + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + clip_a = Clip(character_id=alice.id, track_id=track.id) + doc = _tagged_doc("hello", [(0, 5, clip_a)], characters=[alice], tracks=[track]) + _render(view, doc) + assert len(_clip_block_items(view)) == 1 + + doc.clips = [] + _render(view, doc) + + assert _clip_block_items(view) == [] + + +def test_clip_with_real_audio_path_renders_waveform(qtbot, tmp_path): + view = TimelineView() + qtbot.addWidget(view) + wav_path = tmp_path / "tone.wav" + _write_tone_wav(wav_path) + + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + segment = Segment(audio_path=str(wav_path)) + clip = Clip(character_id=alice.id, track_id=track.id, segments=[segment]) + doc = _tagged_doc("x" * 10, [(0, 10, clip)], characters=[alice], tracks=[track]) + + _render(view, doc) + + block = _clip_block_items(view)[0] + assert block._waveform_item is not None + assert block._waveform_item._peaks is not None + + +def test_clip_with_missing_audio_path_falls_back_to_flat_block(qtbot, tmp_path): + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + segment = Segment(audio_path=str(tmp_path / "does_not_exist.wav")) + clip = Clip(character_id=alice.id, track_id=track.id, segments=[segment]) + doc = _tagged_doc("x" * 10, [(0, 10, clip)], characters=[alice], tracks=[track]) + + _render(view, doc) # must not raise + + block = _clip_block_items(view)[0] + assert block._waveform_item is None + + +# --------------------------------------------------------------------------- +# Play action (per-clip Generate's "hear it" follow-up) +# --------------------------------------------------------------------------- + +def _menu_action_texts(menu): + return [a.text() for a in menu.actions()] + + +def test_context_menu_over_clip_with_audio_shows_generate_and_play(qtbot, tmp_path): + view = TimelineView() + qtbot.addWidget(view) + wav_path = tmp_path / "tone.wav" + _write_tone_wav(wav_path) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + segment = Segment(audio_path=str(wav_path)) + clip = Clip(character_id=alice.id, track_id=track.id, segments=[segment]) + doc = _tagged_doc("x" * 10, [(0, 10, clip)], characters=[alice], tracks=[track]) + _render(view, doc) + + block = _clip_block_items(view)[0] + pos = view.mapFromScene(block.mapToScene(0, 0)) + menu = view._build_context_menu(pos) + + assert _menu_action_texts(menu) == ["Generate", "Play"] + + +def test_context_menu_over_clip_without_audio_shows_generate_only(qtbot): + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + clip = Clip(character_id=alice.id, track_id=track.id) + doc = _tagged_doc("x" * 10, [(0, 10, clip)], characters=[alice], tracks=[track]) + _render(view, doc) + + block = _clip_block_items(view)[0] + pos = view.mapFromScene(block.mapToScene(0, 0)) + menu = view._build_context_menu(pos) + + assert _menu_action_texts(menu) == ["Generate"] + + +def test_triggering_play_emits_play_clip_requested(qtbot, tmp_path): + """Play goes through the owner's transport (seek + play) so the clip is + heard with its read-time post-processing, not the raw segment file.""" + view = TimelineView() + qtbot.addWidget(view) + calls = [] + view.playClipRequested.connect(calls.append) + wav_path = tmp_path / "tone.wav" + _write_tone_wav(wav_path) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + segment = Segment(audio_path=str(wav_path)) + clip = Clip(character_id=alice.id, track_id=track.id, segments=[segment]) + doc = _tagged_doc("x" * 10, [(0, 10, clip)], characters=[alice], tracks=[track]) + _render(view, doc) + + block = _clip_block_items(view)[0] + pos = view.mapFromScene(block.mapToScene(0, 0)) + menu = view._build_context_menu(pos) + play_action = next(a for a in menu.actions() if a.text() == "Play") + play_action.trigger() + + assert calls == [clip.id] + + +# --------------------------------------------------------------------------- +# Per-clip FX button (item 5, "Per-clip FX button") +# --------------------------------------------------------------------------- + +def _click(view, pos, qtbot): + qtbot.mouseClick(view.viewport(), Qt.MouseButton.LeftButton, pos=pos) + + +def _build_doc_with_one_clip(fx_override=None): + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + clip = Clip(character_id=alice.id, track_id=track.id, fx_override=fx_override) + doc = _tagged_doc("x" * 40, [(0, 10, clip)], characters=[alice], tracks=[track]) + return doc, clip, track + + +def test_clip_with_fx_override_renders_fx_active(qtbot): + view = TimelineView() + qtbot.addWidget(view) + doc, _clip, _track = _build_doc_with_one_clip(fx_override={"reverb_enabled": True}) + + _render(view, doc) + + block = _clip_block_items(view)[0] + assert block._fx_active is True + + +def test_clip_without_fx_override_renders_fx_inactive(qtbot): + view = TimelineView() + qtbot.addWidget(view) + doc, _clip, _track = _build_doc_with_one_clip(fx_override=None) + + _render(view, doc) + + block = _clip_block_items(view)[0] + assert block._fx_active is False + + +def test_fx_button_rect_is_anchored_to_bottom_right_corner(): + block = ClipBlockItem() + block.set_geometry(0, 0, 100, 50) + + rect = block.fx_button_rect() + + assert rect.width() == FX_BUTTON_WIDTH_PX + assert rect.height() == FX_BUTTON_HEIGHT_PX + assert rect.right() == 100 + assert rect.bottom() == 50 + + +def test_fx_button_rect_clamps_to_a_block_smaller_than_the_button(): + block = ClipBlockItem() + block.set_geometry(0, 0, MIN_CLIP_WIDTH_PX, 10) # narrower/shorter than the FX button itself + + rect = block.fx_button_rect() + + assert rect.width() <= MIN_CLIP_WIDTH_PX + assert rect.height() <= 10 + assert rect.x() >= 0 + assert rect.y() >= 0 + + +def test_set_fx_active_flips_internal_attribute(): + block = ClipBlockItem() + assert block._fx_active is False + block.set_fx_active(True) + assert block._fx_active is True + block.set_fx_active(False) + assert block._fx_active is False + + +def test_fx_menu_lists_preset_names_plus_clear_fx(qtbot, monkeypatch): + view = TimelineView() + qtbot.addWidget(view) + monkeypatch.setattr( + "kokoro_gui.qt.timeline_view.list_fx_preset_names", lambda: ["Warm", "Telephone"] + ) + doc, clip, _track = _build_doc_with_one_clip() + _render(view, doc) + block = _clip_block_items(view)[0] + + menu = view._build_fx_menu(block) + + assert _menu_action_texts(menu) == ["Warm", "Telephone", "", "Clear FX"] # "" is the separator + + +def test_fx_menu_preset_action_emits_fx_preset_requested_with_clip_id_and_name(qtbot, monkeypatch): + view = TimelineView() + qtbot.addWidget(view) + monkeypatch.setattr("kokoro_gui.qt.timeline_view.list_fx_preset_names", lambda: ["Warm"]) + doc, clip, _track = _build_doc_with_one_clip() + _render(view, doc) + block = _clip_block_items(view)[0] + + menu = view._build_fx_menu(block) + received = [] + view.fxPresetRequested.connect(lambda cid, name: received.append((cid, name))) + next(a for a in menu.actions() if a.text() == "Warm").trigger() + + assert received == [(clip.id, "Warm")] + + +def test_fx_menu_clear_fx_action_emits_fx_preset_requested_with_empty_name(qtbot, monkeypatch): + view = TimelineView() + qtbot.addWidget(view) + monkeypatch.setattr("kokoro_gui.qt.timeline_view.list_fx_preset_names", lambda: []) + doc, clip, _track = _build_doc_with_one_clip(fx_override={"reverb_enabled": True}) + _render(view, doc) + block = _clip_block_items(view)[0] + + menu = view._build_fx_menu(block) + received = [] + view.fxPresetRequested.connect(lambda cid, name: received.append((cid, name))) + next(a for a in menu.actions() if a.text() == "Clear FX").trigger() + + assert received == [(clip.id, "")] + + +def test_click_within_fx_button_rect_bypasses_click_to_select(qtbot, monkeypatch): + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, _track = _build_doc_with_one_clip() + _render(view, doc) + block = _clip_block_items(view)[0] + + handled = [] + monkeypatch.setattr(view, "_handle_fx_button_click", lambda blk, pos: handled.append(blk.clip_id)) + + fx_rect = block.fx_button_rect() + pos = view.mapFromScene(block.mapToScene(fx_rect.center())) + _click(view, pos, qtbot) + + assert handled == [clip.id] + assert selection.selected_clip_id is None # click-to-select did NOT run for this click + assert selection.kind == "none" + + +# --------------------------------------------------------------------------- +# Real time-based positioning (item 6, "Real time-based positioning") +# --------------------------------------------------------------------------- + +def test_clip_with_segments_but_no_audio_is_still_estimated(qtbot): + """Segments that exist but carry no audio_path (not yet generated) are + treated as ungenerated: estimated width, dashed outline.""" + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + segments = [Segment(duration=None), Segment(duration=None)] + clip = Clip(character_id=alice.id, track_id=track.id, segments=segments) + doc = _tagged_doc("x" * 40, [(10, 30, clip)], characters=[alice], tracks=[track]) + + _render(view, doc) + + block = _clip_block_items(view)[0] + assert block.estimated is True + assert block.boundingRect().width() == seconds_to_x(2.0, DEFAULT_PIXELS_PER_SECOND) + + +def test_generated_clip_width_is_its_real_duration(qtbot, tmp_path): + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + wav = tmp_path / "a.wav" + _write_tone_wav(wav) + segment = Segment(duration=2.0, audio_path=str(wav)) + clip = Clip(character_id=alice.id, track_id=track.id, segments=[segment]) + doc = _tagged_doc("x" * 5, [(0, 5, clip)], characters=[alice], tracks=[track]) + + _render(view, doc) + + block = _clip_block_items(view)[0] + assert block.estimated is False + assert block.boundingRect().width() == seconds_to_x(2.0, DEFAULT_PIXELS_PER_SECOND) + + +def test_generated_clip_width_sums_multiple_segment_durations(qtbot, tmp_path): + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + wav = tmp_path / "a.wav" + _write_tone_wav(wav) + segments = [Segment(duration=1.0, audio_path=str(wav)), Segment(duration=2.0, audio_path=str(wav)), + Segment(duration=0.5, audio_path=str(wav))] + clip = Clip(character_id=alice.id, track_id=track.id, segments=segments) + doc = _tagged_doc("x" * 10, [(0, 10, clip)], characters=[alice], tracks=[track]) + + _render(view, doc) + + block = _clip_block_items(view)[0] + assert block.boundingRect().width() == seconds_to_x(3.5, DEFAULT_PIXELS_PER_SECOND) + + +def test_generated_clip_skips_none_duration_segments_without_crashing(qtbot, tmp_path): + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + wav = tmp_path / "a.wav" + _write_tone_wav(wav) + segments = [Segment(duration=1.0, audio_path=str(wav)), Segment(duration=None), + Segment(duration=2.0, audio_path=str(wav))] + clip = Clip(character_id=alice.id, track_id=track.id, segments=segments) + doc = _tagged_doc("x" * 10, [(0, 10, clip)], characters=[alice], tracks=[track]) + + _render(view, doc) # must not raise (summing a None duration) + + block = _clip_block_items(view)[0] + assert block.boundingRect().width() == seconds_to_x(3.0, DEFAULT_PIXELS_PER_SECOND) + + +def test_overlapping_clips_paint_in_ascending_start_offset_order(qtbot): + """Clips are added to the scene in ascending start_offset order, + regardless of document.clips's incidental list order - so the + later-starting clip is always painted on top of an earlier, overlong one + (deterministic paint order, not incidental insertion-list order).""" + view = TimelineView() + qtbot.addWidget(view) + alice = Character.from_preset_dict("Alice", {}) + track = Track(name="Alice", character_id=alice.id) + # Pinned so they overlap on the seconds axis (the default layout is + # end-to-end, which never overlaps). + clip_a = Clip(character_id=alice.id, track_id=track.id, timeline_timestamp=0.5) + clip_b = Clip(character_id=alice.id, track_id=track.id, timeline_timestamp=0.0) + doc = _tagged_doc( + "x" * 100, [(50, 60, clip_a), (10, 20, clip_b)], + characters=[alice], tracks=[track], clips=[], + ) + # Deliberately appended in descending start_offset order: clip_a (larger + # start_offset) first, clip_b (smaller start_offset) after - proving + # render order follows start_offset, not list/insertion order. + doc.clips.append(clip_a) + doc.clips.append(clip_b) + + _render(view, doc) + + blocks = _clip_block_items(view) + assert len(blocks) == 2 + # QGraphicsScene.items() returns items topmost-first; clip_a (the later- + # starting clip, added to the scene last) must be on top. + assert blocks[0].clip_id == clip_a.id + assert blocks[1].clip_id == clip_b.id + + +def test_click_outside_fx_button_rect_still_selects_the_block(qtbot, monkeypatch): + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, _track = _build_doc_with_one_clip() + _render(view, doc) + block = _clip_block_items(view)[0] + + handled = [] + monkeypatch.setattr(view, "_handle_fx_button_click", lambda blk, pos: handled.append(blk.clip_id)) + + # Top-left corner of the block - well outside the bottom-right FX rect. + pos = view.mapFromScene(block.mapToScene(2, 2)) + _click(view, pos, qtbot) + + assert handled == [] + assert selection.selected_clip_id == clip.id + assert selection.kind == "clip" + + +# --------------------------------------------------------------------------- +# Drag-to-reassign a clip to a different track (item 8) +# --------------------------------------------------------------------------- + +def _build_doc_two_tracks_same_character(): + alice = Character.from_preset_dict("Alice", {}) + track_a = Track(name="Alice A", character_id=alice.id, order_index=0) + track_b = Track(name="Alice B", character_id=alice.id, order_index=1) + clip = Clip(character_id=alice.id, track_id=track_a.id) + doc = _tagged_doc("x" * 40, [(0, 10, clip)], characters=[alice], tracks=[track_a, track_b]) + return doc, clip, track_a, track_b + + +def _press_release(view, qtbot, press_pos, release_pos, modifier=Qt.KeyboardModifier.NoModifier): + qtbot.mousePress(view.viewport(), Qt.MouseButton.LeftButton, modifier, pos=press_pos) + qtbot.mouseRelease(view.viewport(), Qt.MouseButton.LeftButton, modifier, pos=release_pos) + + +SHIFT = Qt.KeyboardModifier.ShiftModifier + + +def test_plain_click_on_clip_block_still_selects_it_unchanged(qtbot): + """Regression check (sanity check #1, before any drag-specific test): + a plain click - press and release at the exact same position - must + still select the clip exactly like item 1 already established, now that + press events also record drag-tracking state.""" + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, _track = _build_doc_with_one_clip() + _render(view, doc) + block = _clip_block_items(view)[0] + + pos = view.mapFromScene(block.mapToScene(2, 2)) + _press_release(view, qtbot, pos, pos) + + assert selection.selected_clip_id == clip.id + assert selection.kind == "clip" + + +def test_plain_click_on_fx_button_still_opens_fx_menu_unchanged(qtbot, monkeypatch): + """Regression check (sanity check #2): a plain click on the FX button + rect must still open the FX menu instead of engaging click-to-select or + a drag, exactly like item 5 already established.""" + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, _track = _build_doc_with_one_clip() + _render(view, doc) + block = _clip_block_items(view)[0] + + handled = [] + monkeypatch.setattr(view, "_handle_fx_button_click", lambda blk, pos: handled.append(blk.clip_id)) + + fx_rect = block.fx_button_rect() + pos = view.mapFromScene(block.mapToScene(fx_rect.center())) + _press_release(view, qtbot, pos, pos) + + assert handled == [clip.id] + assert selection.selected_clip_id is None + + +def test_sub_threshold_movement_behaves_as_plain_click_not_a_drag(qtbot): + """A press+release with real movement, but below _drag_threshold_px, + must not be treated as a drag.""" + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, track = _build_doc_with_one_clip() + _render(view, doc) + block = _clip_block_items(view)[0] + + received = [] + view.clipDragReassigned.connect(lambda *a: received.append(a)) + + press_pos = view.mapFromScene(block.mapToScene(2, 2)) + assert view._drag_threshold_px == 8 + release_pos = press_pos + type(press_pos)(3, 0) # 3px, sub-threshold + _press_release(view, qtbot, press_pos, release_pos) + + assert received == [] + assert selection.selected_clip_id == clip.id # click-to-select still ran on press + assert clip.track_id == track.id # untouched + + +def test_drag_release_on_different_track_with_matching_character_emits_signal_no_dialog(qtbot, monkeypatch): + """No ambiguity (target lane's character already matches the clip's) - + must dispatch without ever constructing/exec'ing a QMessageBox.""" + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, track_a, track_b = _build_doc_two_tracks_same_character() + _render(view, doc) + block = _clip_block_items(view)[0] + + def _fail_exec(self): + raise AssertionError("QMessageBox.exec must not be called when there's no ambiguity") + monkeypatch.setattr(QMessageBox, "exec", _fail_exec) + + received = [] + view.clipDragReassigned.connect(lambda *a: received.append(a)) + + press_pos = view.mapFromScene(block.mapToScene(2, 2)) + release_scene_x = block.mapToScene(2, 2).x() + release_pos = view.mapFromScene(QPointF(release_scene_x, lane_top(1) + 10)) + _press_release(view, qtbot, press_pos, release_pos) + + assert received == [(clip.id, track_b.id, False)] + + +def test_drag_release_on_same_track_emits_clip_moved_not_reassigned(qtbot): + """UI9: a horizontal drag on the same lane is a move on the seconds + axis. The view reports the new start; the dock decides timestamp vs. + text reorder.""" + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, track_a, _track_b = _build_doc_two_tracks_same_character() + _render(view, doc) + block = _clip_block_items(view)[0] + + received = [] + moved = [] + view.clipDragReassigned.connect(lambda *a: received.append(a)) + view.clipMoved.connect(lambda *a: moved.append(a)) + + press_pos = view.mapFromScene(block.mapToScene(2, 2)) + release_pos = view.mapFromScene(block.mapToScene(60, 2)) # 58px right = 1.16s at 50px/s + _press_release(view, qtbot, press_pos, release_pos) + + assert received == [] + assert clip.track_id == track_a.id + assert len(moved) == 1 + assert moved[0][0] == clip.id + assert abs(moved[0][1] - 58 / DEFAULT_PIXELS_PER_SECOND) < 0.05 + + +def test_ruler_click_emits_seek(qtbot): + view = TimelineView() + qtbot.addWidget(view) + doc, _clip, _track = _build_doc_with_one_clip() + _render(view, doc) + + seeks = [] + view.seekRequested.connect(seeks.append) + pos = view.mapFromScene(QPointF(seconds_to_x(2.0, DEFAULT_PIXELS_PER_SECOND), RULER_HEIGHT_PX / 2)) + _click(view, pos, qtbot) + + assert len(seeks) == 1 + assert abs(seeks[0] - 2.0) < 0.05 + + +def test_set_playhead_shows_line_at_the_right_x(qtbot): + view = TimelineView() + qtbot.addWidget(view) + doc, _clip, _track = _build_doc_with_one_clip() + _render(view, doc) + + view.set_playhead(1.5) + + line = view._playhead_item + assert line.isVisible() + assert line.line().x1() == seconds_to_x(1.5, DEFAULT_PIXELS_PER_SECOND) + view.set_playhead(None) + assert not line.isVisible() + + +def test_drag_release_off_all_lanes_is_a_noop(qtbot): + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, track_a, _track_b = _build_doc_two_tracks_same_character() + _render(view, doc) + block = _clip_block_items(view)[0] + + received = [] + view.clipDragReassigned.connect(lambda *a: received.append(a)) + + press_pos = view.mapFromScene(block.mapToScene(2, 2)) + release_scene_x = block.mapToScene(2, 2).x() + release_pos = view.mapFromScene(QPointF(release_scene_x, LANE_HEIGHT_PX * 100)) + _press_release(view, qtbot, press_pos, release_pos) # must not crash + + assert received == [] + assert clip.track_id == track_a.id + + +# --------------------------------------------------------------------------- +# Sub-range TTS replacement (item 9): Shift+drag that starts and ends within +# one clip block's own x-range. Behind Shift since the UI shell redesign, so +# a plain drag can mean "move on the seconds axis" (clipMoved). +# --------------------------------------------------------------------------- + +def test_same_track_drag_within_one_block_emits_sub_range_tts_left_to_right(qtbot): + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, _track = _build_doc_with_one_clip() # start=0, end=10, block width 40px + _render(view, doc) + block = _clip_block_items(view)[0] + + received = [] + view.subRangeTtsRequested.connect(lambda *a: received.append(a)) + + press_pos = view.mapFromScene(block.mapToScene(4, 2)) + release_pos = view.mapFromScene(block.mapToScene(20, 2)) + _press_release(view, qtbot, press_pos, release_pos, SHIFT) + + assert len(received) == 1 + cid, sub_start, sub_end = received[0] + assert cid == clip.id + clip_start, clip_end = doc.clip_extent(clip.id) + assert clip_start <= sub_start < sub_end <= clip_end + + +def test_same_track_drag_within_one_block_emits_sub_range_tts_right_to_left(qtbot): + """A right-to-left drag must produce the same, correctly-ordered + (sub_start < sub_end) result as the equivalent left-to-right drag.""" + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, _track = _build_doc_with_one_clip() + _render(view, doc) + block = _clip_block_items(view)[0] + + received = [] + view.subRangeTtsRequested.connect(lambda *a: received.append(a)) + + press_pos = view.mapFromScene(block.mapToScene(20, 2)) + release_pos = view.mapFromScene(block.mapToScene(4, 2)) + _press_release(view, qtbot, press_pos, release_pos, SHIFT) + + assert len(received) == 1 + cid, sub_start, sub_end = received[0] + assert cid == clip.id + assert sub_start < sub_end + clip_start, clip_end = doc.clip_extent(clip.id) + assert clip_start <= sub_start < sub_end <= clip_end + + +def test_shift_drag_exiting_block_bounds_emits_nothing(qtbot): + """A Shift+drag whose x-coordinates exit the origin block's own bounds + is neither a sub-range selection nor a move.""" + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, track_a, _track_b = _build_doc_two_tracks_same_character() + _render(view, doc) + block = _clip_block_items(view)[0] + + drag_received = [] + sub_range_received = [] + view.clipDragReassigned.connect(lambda *a: drag_received.append(a)) + view.subRangeTtsRequested.connect(lambda *a: sub_range_received.append(a)) + + press_pos = view.mapFromScene(block.mapToScene(2, 2)) + release_pos = view.mapFromScene(block.mapToScene(60, 2)) # past the block's own 40px width + _press_release(view, qtbot, press_pos, release_pos, SHIFT) + + assert drag_received == [] + assert sub_range_received == [] + assert clip.track_id == track_a.id + + +def test_different_track_drag_emits_drag_reassigned_not_sub_range_tts(qtbot): + """Regression check: a drag ending on a different track must still + trigger item 8's clipDragReassigned flow, not this new gesture - the two + must never fire for the same drag.""" + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, _track_a, track_b = _build_doc_two_tracks_same_character() + _render(view, doc) + block = _clip_block_items(view)[0] + + drag_received = [] + sub_range_received = [] + view.clipDragReassigned.connect(lambda *a: drag_received.append(a)) + view.subRangeTtsRequested.connect(lambda *a: sub_range_received.append(a)) + + press_pos = view.mapFromScene(block.mapToScene(2, 2)) + release_scene_x = block.mapToScene(2, 2).x() + release_pos = view.mapFromScene(QPointF(release_scene_x, lane_top(1) + 10)) + _press_release(view, qtbot, press_pos, release_pos) + + assert drag_received == [(clip.id, track_b.id, False)] + assert sub_range_received == [] + + +def test_sub_range_drag_too_small_to_select_a_character_emits_nothing(qtbot): + """A drag whose x-coordinates round to the same document-text offset + (too small to select any real character range) must emit nothing at + all - not even a zero-width subRangeTtsRequested. Vertical movement is + used to clear the drag-distance threshold while keeping the horizontal + movement within one character's rounding tolerance and staying on the + clip's own (single) track lane.""" + selection = SelectionModel() + view = TimelineView(selection_model=selection) + qtbot.addWidget(view) + doc, clip, _track = _build_doc_with_one_clip() + _render(view, doc) + block = _clip_block_items(view)[0] + + sub_range_received = [] + drag_received = [] + view.subRangeTtsRequested.connect(lambda *a: sub_range_received.append(a)) + view.clipDragReassigned.connect(lambda *a: drag_received.append(a)) + + press_pos = view.mapFromScene(block.mapToScene(4, 2)) + release_pos = view.mapFromScene(block.mapToScene(5, 70)) + _press_release(view, qtbot, press_pos, release_pos, SHIFT) + + assert sub_range_received == [] + assert drag_received == [] diff --git a/tests/gui_qt/test_transcript_editor.py b/tests/gui_qt/test_transcript_editor.py new file mode 100644 index 0000000..d102095 --- /dev/null +++ b/tests/gui_qt/test_transcript_editor.py @@ -0,0 +1,299 @@ +"""Tests for kokoro_gui/qt/transcript_editor.py's TranscriptEditor - a +`QTextEdit` synced to a `kokoro_gui.daw.models.Document`'s run list +(Claude/PLAN_text_editor_redesign.md). `ClipHighlighter` replaces the +retired `CharacterFxHighlighter`'s two-pass reconciliation with a single +pass over `app.document.runs`, triggered via `TranscriptEditor.rehighlight()`.""" +from PySide6.QtCore import QMimeData, Qt +from PySide6.QtGui import QFocusEvent, QTextCursor + +from kokoro_gui.daw.models import Character + + +def _editor(qt_app): + return qt_app.editor + + +def _set_text_via_real_edit(editor, text): + """Drives a real user-style edit (goes through contentsChange), unlike + load_text/setPlainText which is the deliberately-suppressed path.""" + cursor = editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(text) + + +def _type_line_and_press_enter(qtbot, editor, line_text): + """Simulates real keystrokes (unlike _set_text_via_real_edit's bulk + cursor.insertText) so TranscriptEditor.keyPressEvent's Enter-triggered + [Speaker:FX]: shorthand recognition (TE6's "on completing the line" + grill answer) actually fires.""" + editor.setFocus() + qtbot.keyClicks(editor, line_text) + qtbot.keyClick(editor, Qt.Key.Key_Return) + + +def _highlight_color_at(editor, position): + """ClipHighlighter's setFormat() calls land in the block's QTextLayout + format overlay, not in QTextCursor.charFormat() (that reads the + document's "real" character formatting, a separate store the + highlighter deliberately never touches - see transcript_editor.py's + module docstring for why) and, in this PySide6 version, not reliably in + QTextBlock.textFormats() either (it was observed to return one merged, + un-highlighted range) - so reading a highlighter's actual output back + for assertions means walking QTextBlock.layout().formats() instead, + which does reflect it.""" + block = editor.document().findBlock(position) + offset_in_block = position - block.position() + for fmt_range in block.layout().formats(): + if fmt_range.start <= offset_in_block < fmt_range.start + fmt_range.length: + return fmt_range.format.background().color().name() + return None + + +# --------------------------------------------------------------------------- +# Document sync +# --------------------------------------------------------------------------- + +def test_real_edit_updates_document_text(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + assert qt_app.document.text == "hello world" + + +def test_real_edit_extends_existing_clip_run(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character_id = qt_app.document.characters[0].id + clip = qt_app.document.assign_character_to_range(6, 11, character_id) # "world" + + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.insertText("XYZ ") # insert 4 chars before "hello world" + + assert qt_app.document.text == "XYZ hello world" + assert qt_app.document.clip_extent(clip.id) == (10, 15) + + +def test_load_text_does_not_call_replace_text(qt_app, monkeypatch): + editor = _editor(qt_app) + calls = [] + monkeypatch.setattr( + qt_app.document, "replace_text", + lambda *a, **k: calls.append(a) or [] + ) + editor.load_text("some new text") + assert calls == [] + assert editor.toPlainText() == "some new text" + + +def test_load_text_does_not_disturb_clip_tagging(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character_id = qt_app.document.characters[0].id + clip = qt_app.document.assign_character_to_range(6, 11, character_id) + + editor.load_text("hello world") # same text, reloaded programmatically + + assert qt_app.document.clip_extent(clip.id) == (6, 11) + + +# --------------------------------------------------------------------------- +# Highlighting +# --------------------------------------------------------------------------- + +def test_clip_range_is_highlighted_with_character_color(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + character.highlight_color = "#123456" + qt_app.document.assign_character_to_range(6, 11, character.id) + editor.rehighlight() + + assert _highlight_color_at(editor, 7) == "#123456" + + +def test_text_outside_any_clip_is_not_highlighted(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + qt_app.document.assign_character_to_range(6, 11, character.id) + editor.rehighlight() + + assert _highlight_color_at(editor, 1) is None # inside "hello", not covered by any clip + + +def test_shorthand_line_is_recognized_and_highlighted_on_enter(qtbot, qt_app): + editor = _editor(qt_app) + alice = Character.from_preset_dict("Alice", {"voice": "af_bella"}, highlight_color="#abcdef") + qt_app.document.characters.append(alice) + + _type_line_and_press_enter(qtbot, editor, "[Alice]: hello there") + + assert _highlight_color_at(editor, 10) == "#abcdef" # inside "hello" + clip = qt_app.document.clip_covering(10) + assert clip is not None + assert clip.character_id == alice.id + + +def test_shorthand_line_with_no_matching_character_is_not_recognized(qtbot, qt_app): + editor = _editor(qt_app) + + _type_line_and_press_enter(qtbot, editor, "[NobodyHome]: hello there") + + assert _highlight_color_at(editor, 15) is None + assert qt_app.document.clip_covering(15) is None + + +def test_shorthand_line_not_yet_completed_is_not_recognized(qt_app): + """No Enter pressed and no focus lost - matches the recommended "on + completing the line" behavior's other half: a still-being-typed line + stays plain text.""" + editor = _editor(qt_app) + alice = Character.from_preset_dict("Alice", {"voice": "af_bella"}) + qt_app.document.characters.append(alice) + + _set_text_via_real_edit(editor, "[Alice]: hello the") + + assert qt_app.document.clip_covering(10) is None + + +def test_shorthand_line_is_recognized_on_focus_out_without_enter(qt_app): + """Catches a last line with no trailing Enter, per the grill answer.""" + editor = _editor(qt_app) + alice = Character.from_preset_dict("Alice", {"voice": "af_bella"}) + qt_app.document.characters.append(alice) + _set_text_via_real_edit(editor, "[Alice]: hello there") + + editor.focusOutEvent(QFocusEvent(QFocusEvent.Type.FocusOut, Qt.FocusReason.OtherFocusReason)) + + clip = qt_app.document.clip_covering(10) + assert clip is not None + assert clip.character_id == alice.id + + +def test_already_tagged_line_is_not_reassigned_on_revisit(qtbot, qt_app): + editor = _editor(qt_app) + alice = Character.from_preset_dict("Alice", {"voice": "af_bella"}) + qt_app.document.characters.append(alice) + _type_line_and_press_enter(qtbot, editor, "[Alice]: hello there") + first_clip = qt_app.document.clip_covering(10) + + # Revisiting (focus-out again, cursor still on/near that already-tagged + # line) must not mint a second clip for the same text. + editor.focusOutEvent(QFocusEvent(QFocusEvent.Type.FocusOut, Qt.FocusReason.OtherFocusReason)) + + assert qt_app.document.clip_covering(10).id == first_clip.id + assert len(qt_app.document.clips) == 1 + + +# --------------------------------------------------------------------------- +# Characters menu +# --------------------------------------------------------------------------- + +def _characters_submenu(menu): + return next(a.menu() for a in menu.actions() if a.menu() is not None and a.text() == "Characters") + + +def test_context_menu_lists_characters_and_disables_without_selection(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + editor.textCursor() # no selection made + + menu = editor._build_context_menu() + characters_menu = _characters_submenu(menu) + names = [a.text() for a in characters_menu.actions()] + assert names == [c.name for c in qt_app.document.characters] + assert characters_menu.isEnabled() is False + + +def test_context_menu_characters_submenu_enabled_with_selection(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.setPosition(5, QTextCursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + + menu = editor._build_context_menu() + characters_menu = _characters_submenu(menu) + assert characters_menu.isEnabled() is True + + +def test_assign_character_directly_creates_clip_and_rehighlights(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + character.highlight_color = "#654321" + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.setPosition(5, QTextCursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + + editor._assign_character(character.id) + + clip = qt_app.document.clip_covering(0) + assert clip is not None + assert clip.character_id == character.id + assert qt_app.document.clip_extent(clip.id) == (0, 5) + assert _highlight_color_at(editor, 0) == "#654321" + + +# --------------------------------------------------------------------------- +# Copy/paste split-vs-inherit +# --------------------------------------------------------------------------- + +def _mime_with_character(text, character_id): + mime = QMimeData() + mime.setText(text) + mime.setData( + "application/x-kokorogui-character-id", + character_id.encode("utf-8"), + ) + return mime + + +def test_paste_with_splits_enabled_creates_clip_for_source_character(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + qt_app.settings["character_fx_paste_splits"] = True + + cursor = editor.textCursor() + cursor.setPosition(11) + editor.setTextCursor(cursor) + editor.insertFromMimeData(_mime_with_character(" PASTED", character.id)) + + assert qt_app.document.text == "hello world PASTED" + clip = qt_app.document.clip_covering(11) + assert clip is not None + assert clip.character_id == character.id + + +def test_paste_with_splits_disabled_does_not_create_clip(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + qt_app.settings["character_fx_paste_splits"] = False + + cursor = editor.textCursor() + cursor.setPosition(11) + editor.setTextCursor(cursor) + editor.insertFromMimeData(_mime_with_character(" PASTED", character.id)) + + assert qt_app.document.text == "hello world PASTED" + assert qt_app.document.clip_covering(11) is None + + +def test_create_mime_data_from_selection_tags_source_character(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + qt_app.document.assign_character_to_range(6, 11, character.id) # "world" + + cursor = editor.textCursor() + cursor.setPosition(6) + cursor.setPosition(11, QTextCursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + + mime = editor.createMimeDataFromSelection() + tagged_id = bytes(mime.data(editor.CHARACTER_ID_MIME_TYPE)).decode("utf-8") + assert tagged_id == character.id diff --git a/tests/gui_qt/test_transcript_gutter.py b/tests/gui_qt/test_transcript_gutter.py new file mode 100644 index 0000000..b2dadea --- /dev/null +++ b/tests/gui_qt/test_transcript_gutter.py @@ -0,0 +1,199 @@ +"""Tests for kokoro_gui/qt/transcript_editor.py's TranscriptGutter (TE3) - +the left gutter's "Character: X" labels, change-only painting, and +click-to-reassign picker.""" +from PySide6.QtGui import QPaintEvent + +from kokoro_gui.daw.models import Character +from kokoro_gui.qt.transcript_editor import GUTTER_WIDTH_PX + + +def _editor(qt_app): + return qt_app.editor + + +def _gutter(qt_app): + return _editor(qt_app)._gutter + + +def _repaint(gutter): + """Directly invokes paintEvent (no real display needed) so + `_label_rects` gets (re)populated the same way a real paint would.""" + gutter.paintEvent(QPaintEvent(gutter.rect())) + + +def _set_text(editor, text): + editor.setPlainText("") # ensure a clean slate regardless of prior state + from PySide6.QtGui import QTextCursor + cursor = editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(text) + + +def test_gutter_has_expected_width(qt_app): + gutter = _gutter(qt_app) + assert gutter.sizeHint().width() == GUTTER_WIDTH_PX + assert gutter.parent() is _editor(qt_app) + + +def test_gutter_labels_a_tagged_line(qt_app): + editor = _editor(qt_app) + gutter = _gutter(qt_app) + _set_text(editor, "hello world") + alice = qt_app.document.characters[0] + qt_app.document.assign_character_to_range(0, 5, alice.id) + editor.rehighlight() + + _repaint(gutter) + + # _label_rects stores the painted LINE's range (a block can be wider + # than the clip it starts with a label for) - the picker menu widens + # this out to the clip's actual full extent at click time (see + # test_picker_widens_range_to_the_clips_full_extent_not_just_the_clicked_line). + assert len(gutter._label_rects) == 1 + _rect, line_start, _line_end = gutter._label_rects[0] + assert line_start == 0 + + +def test_gutter_omits_label_for_untagged_text(qt_app): + editor = _editor(qt_app) + gutter = _gutter(qt_app) + _set_text(editor, "hello world") + + _repaint(gutter) + + assert gutter._label_rects == [] + + +def test_gutter_labels_only_where_character_changes_across_lines(qt_app): + editor = _editor(qt_app) + gutter = _gutter(qt_app) + alice = qt_app.document.characters[0] + _set_text(editor, "line one\nline two\nline three") + # Tag all three lines to the SAME character - only the first line (where + # the change from "untagged" happens) should get a label. + qt_app.document.assign_character_to_range(0, len(qt_app.document.text), alice.id) + editor.rehighlight() + + _repaint(gutter) + + assert len(gutter._label_rects) == 1 + + +def test_gutter_relabels_when_character_changes_mid_document(qt_app): + editor = _editor(qt_app) + gutter = _gutter(qt_app) + bob = Character.from_preset_dict("Bob", {"voice": "am_michael"}) + qt_app.document.characters.append(bob) + alice = qt_app.document.characters[0] + text = "line one\nline two" + _set_text(editor, text) + first_line_end = text.index("\n") + qt_app.document.assign_character_to_range(0, first_line_end, alice.id) + qt_app.document.assign_character_to_range(first_line_end, len(text), bob.id) + editor.rehighlight() + + _repaint(gutter) + + assert len(gutter._label_rects) == 2 + + +def test_gutter_label_shows_fx_indicator_when_override_set(qt_app): + editor = _editor(qt_app) + gutter = _gutter(qt_app) + _set_text(editor, "hello world") + alice = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(0, 5, alice.id) + clip.fx_override = {"reverb_enabled": True} + editor.rehighlight() + + _repaint(gutter) + + # Can't read drawn pixel text back directly, but the label rect existing + # at all (with the fx_override set) confirms the code path that appends + # " FX" ran without raising - covered end-to-end below by asserting the + # click handler still resolves the same clip/range correctly. + assert len(gutter._label_rects) == 1 + + +def test_clicking_a_label_opens_a_picker_listing_characters(qt_app): + editor = _editor(qt_app) + gutter = _gutter(qt_app) + _set_text(editor, "hello world") + alice = qt_app.document.characters[0] + bob = Character.from_preset_dict("Bob", {"voice": "am_michael"}) + qt_app.document.characters.append(bob) + qt_app.document.assign_character_to_range(0, 5, alice.id) + editor.rehighlight() + _repaint(gutter) + + rect, line_start, line_end = gutter._label_rects[0] + menu = gutter._build_picker_menu(line_start, line_end) + + assert [a.text() for a in menu.actions()] == [c.name for c in qt_app.document.characters] + + +def test_picker_action_reassigns_the_clicked_clip(qt_app): + editor = _editor(qt_app) + gutter = _gutter(qt_app) + _set_text(editor, "hello world") + alice = qt_app.document.characters[0] + bob = Character.from_preset_dict("Bob", {"voice": "am_michael"}) + qt_app.document.characters.append(bob) + clip = qt_app.document.assign_character_to_range(0, 5, alice.id) + editor.rehighlight() + _repaint(gutter) + rect, line_start, line_end = gutter._label_rects[0] + + menu = gutter._build_picker_menu(line_start, line_end) + bob_action = next(a for a in menu.actions() if a.text() == "Bob") + bob_action.trigger() + + new_clip = qt_app.document.clip_covering(0) + assert new_clip.character_id == bob.id + assert new_clip.id != clip.id # reassignment mints a fresh clip, same as the Characters menu + + +def test_picker_widens_range_to_the_clips_full_extent_not_just_the_clicked_line(qt_app): + """A clip can span more text than the one line its label happens to be + painted next to (a multi-line clip, or one that only changed on its + first line) - the picker must act on the whole clip, not just that line.""" + editor = _editor(qt_app) + gutter = _gutter(qt_app) + alice = qt_app.document.characters[0] + bob = Character.from_preset_dict("Bob", {"voice": "am_michael"}) + qt_app.document.characters.append(bob) + text = "line one\nline two" + _set_text(editor, text) + qt_app.document.assign_character_to_range(0, len(text), alice.id) + editor.rehighlight() + _repaint(gutter) + assert len(gutter._label_rects) == 1 + rect, line_start, line_end = gutter._label_rects[0] + assert line_end < len(text) # the label's own line is shorter than the whole clip + + menu = gutter._build_picker_menu(line_start, line_end) + next(a for a in menu.actions() if a.text() == "Bob").trigger() + + # The reassignment covered the WHOLE clip (through "line two", not just + # "line one" where the label happened to be painted). + assert qt_app.document.clip_covering(0).character_id == bob.id + assert qt_app.document.clip_covering(len(text) - 1).character_id == bob.id + + +def test_gutter_resizes_with_the_editor(qt_app): + """Drives TranscriptEditor.resizeEvent directly (no real display/shown + window in this fixture to guarantee a queued QResizeEvent gets + delivered synchronously) - same "call the Qt override directly, no + event loop needed" precedent as _repaint above.""" + from PySide6.QtCore import QSize + from PySide6.QtGui import QResizeEvent + + editor = _editor(qt_app) + gutter = _gutter(qt_app) + old_size = editor.size() + new_size = QSize(600, 400) + editor.resize(new_size) + editor.resizeEvent(QResizeEvent(new_size, old_size)) + + assert gutter.geometry().width() == GUTTER_WIDTH_PX + assert gutter.geometry().height() == editor.height() diff --git a/tests/gui_qt/test_transcript_panel.py b/tests/gui_qt/test_transcript_panel.py new file mode 100644 index 0000000..f936fca --- /dev/null +++ b/tests/gui_qt/test_transcript_panel.py @@ -0,0 +1,303 @@ +"""Tests for the Transcript dock (Claude/PLAN_ui_shell_redesign.md section +2): the Character/FX header combos, the two-line gutter labels with per-clip +play buttons, the dirty underline, split rules, and the playing-clip +highlight.""" +import json +import os + +from PySide6.QtGui import QPaintEvent, QTextCharFormat, QTextCursor + +from kokoro_gui.daw.dirty import build_segments_from_results, compute_expected_cache_hash +from kokoro_gui.daw.models import Character + +import kokoro_gui.qt.app # noqa: F401 - app.py must load before any docks module (circular import) +from kokoro_gui.qt.docks.transcript_dock import FX_EDIT_LABEL, FX_NONE_LABEL # noqa: E402 + + +def _type(editor, text): + cursor = editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(text) + + +def _place_caret(editor, position): + cursor = editor.textCursor() + cursor.setPosition(position) + editor.setTextCursor(cursor) + + +def _repaint_gutter(editor): + gutter = editor._gutter + gutter.paintEvent(QPaintEvent(gutter.rect())) + return gutter + + +def _mark_generated(qt_app, clip, path="x.wav"): + """A clean clip needs a key that matches and a file that exists (a + segment whose file is missing is dirty, grill TB11); the qt_app fixture + chdirs into tmp_path so the relative `path` lands there.""" + text = qt_app.document.clip_text(clip) + config = qt_app.document.effective_config_for_clip(clip) + expected = compute_expected_cache_hash(text, config) + with open(path, "wb") as f: + f.write(b"RIFF") + clip.segments = build_segments_from_results(expected, [{"text": text, "path": path, "duration": 1.0}]) + + +def _write_fx_preset(qt_app, name): + import kokoro_gui.qt.app as qt_app_module + + os.makedirs(qt_app_module.FX_PRESETS_DIR, exist_ok=True) + with open(os.path.join(qt_app_module.FX_PRESETS_DIR, f"{name}.json"), "w", encoding="utf-8") as f: + json.dump({"reverb_enabled": True, "reverb_wet_level": 0.4}, f) + qt_app.engine.load_fx_preset.return_value = {"reverb_enabled": True, "reverb_wet_level": 0.4} + qt_app.transcript_dock.refresh_fx_choices() + + +# -- header combos -------------------------------------------------------------- + + +def test_header_combos_follow_the_caret(qt_app): + dock = qt_app.transcript_dock + editor = dock.editor + bob = Character.from_preset_dict("Bob", {"voice": "am_michael", "fx_preset": "Echo"}) + qt_app.document.characters.append(bob) + dock.refresh_character_choices() + _write_fx_preset(qt_app, "Echo") + _type(editor, "hello world") + alice = qt_app.document.characters[0] + qt_app.document.assign_character_to_range(0, 5, alice.id) + qt_app.document.assign_character_to_range(6, 11, bob.id) + editor.rehighlight() + + _place_caret(editor, 2) + assert dock.character_combo.currentData() == alice.id + assert dock.fx_combo.currentText() == FX_NONE_LABEL + + _place_caret(editor, 8) + assert dock.character_combo.currentData() == bob.id + assert dock.fx_combo.currentText() == "Echo" # inherited from Bob's preset + + +def test_character_combo_assigns_the_selection(qt_app): + dock = qt_app.transcript_dock + editor = dock.editor + bob = Character.from_preset_dict("Bob", {}) + qt_app.document.characters.append(bob) + dock.refresh_character_choices() + _type(editor, "hello world") + cursor = editor.textCursor() + cursor.setPosition(6) + cursor.setPosition(11, QTextCursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + + index = dock.character_combo.findData(bob.id) + dock.character_combo.setCurrentIndex(index) + dock.character_combo.activated.emit(index) + + clip = qt_app.document.clip_covering(8) + assert clip is not None and clip.character_id == bob.id + assert qt_app.document.clip_covering(2) is None + + +def test_character_combo_without_selection_retargets_the_carets_whole_clip(qt_app): + dock = qt_app.transcript_dock + editor = dock.editor + bob = Character.from_preset_dict("Bob", {}) + qt_app.document.characters.append(bob) + dock.refresh_character_choices() + _type(editor, "hello world") + alice = qt_app.document.characters[0] + qt_app.document.assign_character_to_range(0, 11, alice.id) + editor.rehighlight() + _place_caret(editor, 3) + + index = dock.character_combo.findData(bob.id) + dock.character_combo.setCurrentIndex(index) + dock.character_combo.activated.emit(index) + + assert qt_app.document.clip_covering(0).character_id == bob.id + assert qt_app.document.clip_covering(10).character_id == bob.id + assert qt_app.document.undo_stack.can_undo() + + +def test_fx_combo_sets_an_undoable_named_override(qt_app): + dock = qt_app.transcript_dock + editor = dock.editor + _write_fx_preset(qt_app, "Telephone") + _type(editor, "hello world") + alice = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(0, 11, alice.id) + editor.rehighlight() + _place_caret(editor, 3) + + index = dock.fx_combo.findData("Telephone") + dock.fx_combo.setCurrentIndex(index) + dock.fx_combo.activated.emit(index) + + assert clip.fx_override == {"reverb_enabled": True, "reverb_wet_level": 0.4} + assert clip.overrides["fx_preset"] == "Telephone" + assert dock.fx_combo.currentText() == "Telephone" + qt_app.undo() + assert clip.fx_override is None + assert "fx_preset" not in clip.overrides + + +def test_fx_combo_edit_entry_raises_the_fx_tab(qt_app): + dock = qt_app.transcript_dock + raised = [] + qt_app.raise_fx_tab = lambda: raised.append(True) + index = dock.fx_combo.findData("__edit__") + assert dock.fx_combo.itemText(index) == FX_EDIT_LABEL + dock.fx_combo.activated.emit(index) + assert raised == [True] + + +def test_character_combo_manage_entry_opens_the_dialog(qt_app): + dock = qt_app.transcript_dock + opened = [] + qt_app.open_characters_dialog = lambda: opened.append(True) + index = dock.character_combo.findData("__manage__") + dock.character_combo.activated.emit(index) + assert opened == [True] + + +# -- gutter ----------------------------------------------------------------------- + + +def test_gutter_draws_a_play_button_for_each_dirty_clip_only(qt_app): + editor = qt_app.editor + alice = qt_app.document.characters[0] + _type(editor, "line one\nline two\nline three") + text = qt_app.document.text + first_end = text.index("\n") + second_end = text.index("\n", first_end + 1) + dirty = qt_app.document.assign_character_to_range(0, first_end, alice.id) + clean = qt_app.document.assign_character_to_range(first_end + 1, second_end, alice.id) + _mark_generated(qt_app, clean) + editor.rehighlight() + + gutter = _repaint_gutter(editor) + + assert [cid for _rect, cid in gutter.button_rects()] == [dirty.id] + + +def test_gutter_play_button_click_runs_scoped_generate(qt_app): + editor = qt_app.editor + alice = qt_app.document.characters[0] + _type(editor, "hello world") + clip = qt_app.document.assign_character_to_range(0, 11, alice.id) + editor.rehighlight() + gutter = _repaint_gutter(editor) + requested = [] + qt_app.generate_clip = lambda cid: requested.append(cid) + + from PySide6.QtCore import QEvent, QPointF, Qt + from PySide6.QtGui import QMouseEvent + + rect, cid = gutter.button_rects()[0] + assert cid == clip.id + pos = QPointF(rect.center()) + event = QMouseEvent(QEvent.Type.MouseButtonPress, pos, pos, Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier) + gutter.mousePressEvent(event) + + assert requested == [clip.id] + + +def test_gutter_key_includes_fx_so_a_preset_change_relabels(qt_app): + editor = qt_app.editor + alice = qt_app.document.characters[0] + _write_fx_preset(qt_app, "Echo") + _type(editor, "line one\nline two") + text = qt_app.document.text + first_end = text.index("\n") + qt_app.document.assign_character_to_range(0, first_end, alice.id) + second = qt_app.document.assign_character_to_range(first_end + 1, len(text), alice.id) + editor.rehighlight() + assert len(_repaint_gutter(editor)._label_rects) == 1 # same character, same (no) FX + + second.overrides["fx_preset"] = "Echo" + assert len(_repaint_gutter(editor)._label_rects) == 2 # FX differs -> new label + + +# -- dirty underline ------------------------------------------------------------------- + + +def _underline_at(editor, position): + block = editor.document().findBlock(position) + offset = position - block.position() + for fmt_range in block.layout().formats(): + if fmt_range.start <= offset < fmt_range.start + fmt_range.length: + return fmt_range.format.underlineStyle() + return None + + +def test_dirty_clip_text_is_dash_underlined_and_clean_text_is_not(qt_app): + editor = qt_app.editor + alice = qt_app.document.characters[0] + _type(editor, "hello world") + dirty = qt_app.document.assign_character_to_range(0, 5, alice.id) + clean = qt_app.document.assign_character_to_range(6, 11, alice.id) + _mark_generated(qt_app, clean) + editor.rehighlight() + + assert _underline_at(editor, 2) == QTextCharFormat.UnderlineStyle.DashUnderline + assert _underline_at(editor, 8) == QTextCharFormat.UnderlineStyle.NoUnderline + del dirty + + +# -- split rules --------------------------------------------------------------------------- + + +def test_split_rules_mark_clip_boundaries_and_planned_splits(qt_app): + editor = qt_app.editor + bob = Character.from_preset_dict("Bob", {}) + qt_app.document.characters.append(bob) + _type(editor, "[Bob]: first para.\n\nsecond para.\n\n[Default]: third.") + qt_app.settings["auto_split_by_paragraph"] = True + + editor.refresh_split_rules() + + text = qt_app.document.text + boundaries = editor.split_boundaries() + assert text.index("second") in boundaries + assert text.index("[Default]") in boundaries + assert 0 not in boundaries and len(text) not in boundaries + + +def test_split_rules_refresh_after_an_edit_is_debounced(qt_app, qtbot): + editor = qt_app.editor + alice = qt_app.document.characters[0] + _type(editor, "hello world") + qt_app.document.assign_character_to_range(0, 5, alice.id) + editor.rehighlight() + assert editor.split_boundaries() == [5] + + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.insertText("XX") # untagged insert at the very start shifts the clip to [2, 7) + qtbot.waitUntil(lambda: editor.split_boundaries() == [2, 7], timeout=2000) + + +# -- playing clip -------------------------------------------------------------------------- + + +def test_playing_clip_gets_an_extra_selection_without_moving_the_caret(qt_app): + editor = qt_app.editor + alice = qt_app.document.characters[0] + _type(editor, "hello world") + clip = qt_app.document.assign_character_to_range(6, 11, alice.id) + editor.rehighlight() + _place_caret(editor, 1) + + qt_app.selection.set_playing_clip(clip.id) + + selections = editor.extraSelections() + assert len(selections) == 1 + assert (selections[0].cursor.selectionStart(), selections[0].cursor.selectionEnd()) == (6, 11) + assert editor.textCursor().position() == 1 + assert qt_app.selection.selected_clip_id is None # playback never selects + + qt_app.selection.set_playing_clip(None) + assert editor.extraSelections() == [] diff --git a/tests/gui_qt/test_transcript_selection_sync.py b/tests/gui_qt/test_transcript_selection_sync.py new file mode 100644 index 0000000..9b62bf2 --- /dev/null +++ b/tests/gui_qt/test_transcript_selection_sync.py @@ -0,0 +1,94 @@ +"""Tests for the transcript <-> timeline selection sync (item 1, "Sync +layer") - full qt_app fixture, covering TranscriptEditor's +_on_cursor_position_changed/_on_selection_model_changed and the round trip +through TimelineView's click-to-select.""" +from PySide6.QtCore import Qt +from PySide6.QtGui import QTextCursor + +from kokoro_gui.qt.timeline_view import ClipBlockItem + + +def _editor(qt_app): + return qt_app.editor + + +def _set_text_via_real_edit(editor, text): + cursor = editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(text) + + +def _clip_block_items(qt_app): + return [item for item in qt_app.timeline_dock.timeline_view._scene.items() if isinstance(item, ClipBlockItem)] + + +def test_caret_inside_clip_range_selects_that_clip(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(6, 11, character.id) # "world" + + cursor = editor.textCursor() + cursor.setPosition(8) + editor.setTextCursor(cursor) + + assert qt_app.selection.selected_clip_id == clip.id + assert qt_app.selection.kind == "clip" + + +def test_drag_selection_starting_inside_clip_selects_that_clip(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(6, 11, character.id) # "world" + + cursor = editor.textCursor() + cursor.setPosition(6) + cursor.setPosition(9, QTextCursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + + assert qt_app.selection.selected_clip_id == clip.id + assert qt_app.selection.kind == "clip" + + +def test_caret_in_plain_text_with_no_covering_clip_yields_none(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + + cursor = editor.textCursor() + cursor.setPosition(2) + editor.setTextCursor(cursor) + + assert qt_app.selection.kind == "none" + + +def test_drag_selection_over_plain_text_yields_exact_range(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + + cursor = editor.textCursor() + cursor.setPosition(0) + cursor.setPosition(5, QTextCursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + + assert qt_app.selection.kind == "range" + assert qt_app.selection.selected_range == (0, 5) + + +def test_clicking_timeline_clip_block_moves_transcript_cursor_to_clip_range(qt_app, qtbot): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + clip = qt_app.document.assign_character_to_range(6, 11, character.id) # "world" + qt_app.refresh_timeline() + + view = qt_app.timeline_dock.timeline_view + block = _clip_block_items(qt_app)[0] + assert block.clip_id == clip.id + pos = view.mapFromScene(block.mapToScene(5, 5)) + qtbot.mouseClick(view.viewport(), Qt.MouseButton.LeftButton, pos=pos) + + cursor = editor.textCursor() + expected_start, expected_end = qt_app.document.clip_extent(clip.id) + assert cursor.selectionStart() == expected_start + assert cursor.selectionEnd() == expected_end diff --git a/tests/gui_qt/test_undo_redo.py b/tests/gui_qt/test_undo_redo.py new file mode 100644 index 0000000..90bb9e7 --- /dev/null +++ b/tests/gui_qt/test_undo_redo.py @@ -0,0 +1,158 @@ +"""GUI-level tests for item 4 ("Undo/redo") - full qt_app fixture, covering +the app's undo()/redo() methods, its first menu bar (Edit > Undo/Redo), and +the transcript editor pushing commands instead of mutating the document +directly.""" +from PySide6.QtGui import QKeySequence, QTextCursor + +from kokoro_gui.qt.timeline_view import ClipBlockItem + + +def _editor(qt_app): + return qt_app.editor + + +def _set_text_via_real_edit(editor, text): + cursor = editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(text) + + +def _clip_block_items(qt_app): + return [item for item in qt_app.timeline_dock.timeline_view._scene.items() if isinstance(item, ClipBlockItem)] + + +# --------------------------------------------------------------------------- +# Typing + undo resyncs both the document and the visible widget +# --------------------------------------------------------------------------- + +def test_typing_then_undo_restores_previous_text_in_document_and_widget(qt_app): + # One bulk edit, not two adjacent inserts - Qt's native undo merges + # adjacent same-position insertions into a single command (verified + # directly against QTextDocument), so this exercises exactly one + # native-stack undo, cleanly. + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + assert qt_app.document.text == "hello world" + + qt_app.undo() + + assert qt_app.document.text == "" + assert editor.toPlainText() == "" + + +def test_undo_then_redo_reapplies_the_typed_text(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + + qt_app.undo() + qt_app.redo() + + assert qt_app.document.text == "hello world" + assert editor.toPlainText() == "hello world" + + +def test_typing_then_assigning_then_undo_twice_reverts_assignment_then_typing(qt_app): + """The coordinated-dual-stack behavior this whole rebuild grilled for: + a native (typing) edit followed by a custom-stack (character + assignment) action undoes in the right order - the more recent action + (the assignment, on the custom stack) first, then the older one (the + typing, on the native stack) - regardless of which stack each came + from.""" + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + cursor = editor.textCursor() + cursor.setPosition(6) + cursor.setPosition(11, QTextCursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + editor._assign_character(character.id) + assert len(qt_app.document.clips) == 1 + + qt_app.undo() + assert qt_app.document.clips == [] + assert qt_app.document.text == "hello world" # typing survives this first undo + + qt_app.undo() + assert qt_app.document.text == "" + + +# --------------------------------------------------------------------------- +# Characters menu + undo +# --------------------------------------------------------------------------- + +def test_assign_character_then_undo_removes_the_clip(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + + editor._assign_character(character.id) + assert len(qt_app.document.clips) == 0 # cursor had no selection - no-op + + cursor = editor.textCursor() + cursor.setPosition(6) + cursor.setPosition(11, QTextCursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + editor._assign_character(character.id) + assert len(qt_app.document.clips) == 1 + + qt_app.undo() + + assert qt_app.document.clips == [] + assert _clip_block_items(qt_app) == [] + + +def test_assign_character_undo_redo_restores_clip(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + character = qt_app.document.characters[0] + cursor = editor.textCursor() + cursor.setPosition(6) + cursor.setPosition(11, QTextCursor.MoveMode.KeepAnchor) + editor.setTextCursor(cursor) + editor._assign_character(character.id) + + qt_app.undo() + qt_app.redo() + + assert len(qt_app.document.clips) == 1 + clip = qt_app.document.clips[0] + assert qt_app.document.clip_extent(clip.id) == (6, 11) + assert clip.character_id == character.id + + +# --------------------------------------------------------------------------- +# Menu bar +# --------------------------------------------------------------------------- + +def test_menu_bar_has_edit_menu_with_undo_redo_actions(qt_app): + menu_bar = qt_app.menuBar() + assert menu_bar is not None + + menu_titles = [action.text() for action in menu_bar.actions()] + assert any("Edit" in title for title in menu_titles) + + assert qt_app.undo_action.text() == "Undo" + assert qt_app.redo_action.text() == "Redo" + assert qt_app.undo_action.shortcut() == QKeySequence(QKeySequence.StandardKey.Undo) + assert qt_app.redo_action.shortcut() == QKeySequence(QKeySequence.StandardKey.Redo) + + +def test_undo_action_trigger_calls_undo(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + + qt_app.undo_action.trigger() + + assert qt_app.document.text == "" + assert editor.toPlainText() == "" + + +def test_redo_action_trigger_calls_redo(qt_app): + editor = _editor(qt_app) + _set_text_via_real_edit(editor, "hello world") + qt_app.undo_action.trigger() + + qt_app.redo_action.trigger() + + assert qt_app.document.text == "hello world" + assert editor.toPlainText() == "hello world" diff --git a/tests/gui_qt/test_waveform_view.py b/tests/gui_qt/test_waveform_view.py new file mode 100644 index 0000000..413f82d --- /dev/null +++ b/tests/gui_qt/test_waveform_view.py @@ -0,0 +1,85 @@ +"""Tests for kokoro_gui/qt/waveform_view.py's WaveformItem/WaveformView - +needs qtbot/offscreen but not the full qt_app (QtTTSApp) fixture, since this +widget has no dependency on the app at all (Workstream 3 spike).""" +import numpy as np +import soundfile as sf + +from kokoro_gui.qt.waveform_view import WaveformItem, WaveformView + + +def _write_silent_wav(path, sample_rate=8000, seconds=0.25): + data = np.zeros(int(sample_rate * seconds), dtype=np.float32) + sf.write(str(path), data, sample_rate) + + +def _write_tone_wav(path, sample_rate=8000, seconds=0.25, freq=440): + t = np.linspace(0, seconds, int(sample_rate * seconds), endpoint=False) + data = (0.5 * np.sin(2 * np.pi * freq * t)).astype(np.float32) + sf.write(str(path), data, sample_rate) + + +def test_widget_constructs_without_crash_offscreen(qtbot): + view = WaveformView() + qtbot.addWidget(view) + assert view.duration == 0.0 + + +def test_bounding_rect_matches_set_peaks_dimensions(): + item = WaveformItem() + peaks = np.zeros((10, 2), dtype=np.float32) + item.set_peaks(peaks, width=200.0, height=80.0) + rect = item.boundingRect() + assert rect.width() == 200.0 + assert rect.height() == 80.0 + + +def test_load_audio_sets_peaks_and_duration(qtbot, tmp_path): + path = tmp_path / "tone.wav" + _write_tone_wav(path) + view = WaveformView() + qtbot.addWidget(view) + view.show() + view.resize(300, 100) + qtbot.wait(10) + + view.load_audio(str(path)) + + assert view.duration > 0 + bucket_count = max(1, view.viewport().width()) + assert view.waveform_item._peaks.shape == (bucket_count, 2) + + +def test_resize_recomputes_bucket_count(qtbot, tmp_path): + # QGraphicsView.resize() only actually changes viewport geometry once + # the widget has been shown at least once - a plain resize() on a + # never-shown widget is a no-op even under the offscreen platform. + path = tmp_path / "tone.wav" + _write_tone_wav(path) + view = WaveformView() + qtbot.addWidget(view) + view.show() + view.resize(150, 80) + qtbot.wait(10) + view.load_audio(str(path)) + first_count = view.waveform_item._peaks.shape[0] + + view.resize(400, 80) + qtbot.wait(10) + + second_count = view.waveform_item._peaks.shape[0] + assert second_count != first_count + assert second_count == max(1, view.viewport().width()) + + +def test_load_audio_with_silent_file_does_not_crash(qtbot, tmp_path): + path = tmp_path / "silence.wav" + _write_silent_wav(path) + view = WaveformView() + qtbot.addWidget(view) + view.show() + view.resize(200, 80) + qtbot.wait(10) + + view.load_audio(str(path)) + + assert np.all(view.waveform_item._peaks == 0.0) diff --git a/tests/gui_qt/test_welcome_dialog.py b/tests/gui_qt/test_welcome_dialog.py new file mode 100644 index 0000000..5354cde --- /dev/null +++ b/tests/gui_qt/test_welcome_dialog.py @@ -0,0 +1,127 @@ +"""Welcome dialog (grill WF2, revised): kokoro_gui/qt/welcome_dialog.py and +the `show_welcome` / File > Welcome... wiring in app.py.""" +import os + +from PySide6.QtGui import QTextCursor + +from kokoro_gui.daw.models import Character +from kokoro_gui.qt.welcome_dialog import MISSING_SUFFIX + + +def _type(editor, text): + cursor = editor.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(text) + + +def test_default_on_and_checkbox_turns_it_off(qt_app): + assert qt_app.settings["show_welcome"] is True + dialog = qt_app.show_welcome_if_enabled() + assert dialog is not None and dialog.isVisible() + + dialog.show_at_startup.setChecked(False) + dialog.reject() + assert qt_app.settings["show_welcome"] is False + assert qt_app.show_welcome_if_enabled() is None + # The menu action still brings it back regardless. + assert qt_app.show_welcome().isVisible() + + +def test_fixture_never_opens_it(qt_app): + assert qt_app.welcome_dialog is None + + +def _save_as(qt_app, path): + qt_app.save_project_as(path) + qt_app.wait_for_project_io() + return qt_app.project_path + + +def test_lists_recent_with_current_first_and_resume_default(qt_app, tmp_path): + one = _save_as(qt_app, str(tmp_path / "one.tbaw")) + _save_as(qt_app, str(tmp_path / "two.tbaw")) + dialog = qt_app.show_welcome() + + assert dialog.paths()[:2] == [qt_app.project_path, one] + assert dialog.selected_path() == qt_app.project_path + assert dialog.open_btn.text() == "Resume" + assert dialog.characters_label.text() == str(len(qt_app.document.characters)) + assert dialog.duration_label.text() == "0:00" + + dialog.list.setCurrentRow(1) + assert dialog.open_btn.text() == "Open" + assert dialog.path_label.text() == one + + +def test_missing_row_is_disabled_and_can_be_removed(qt_app, tmp_path): + from kokoro_gui.qt import project as project_io + + ghost = str(tmp_path / "ghost.json") + project_io.remember_recent(qt_app.settings, ghost) + qt_app.settings["last_project"] = qt_app.project_path + dialog = qt_app.show_welcome() + + row = dialog.paths().index(ghost) + item = dialog.list.item(row) + assert item.text().endswith(MISSING_SUFFIX) + assert not item.flags() & item.flags().ItemIsEnabled + + dialog.remove_from_recent(ghost) + assert ghost not in dialog.paths() + assert ghost not in qt_app.settings["recent_projects"] + assert all(a.text() != "ghost" for a in qt_app.recent_menu.actions()) + + +def test_clear_list_keeps_only_current_project(qt_app, tmp_path): + qt_app.save_project_as(str(tmp_path / "one.json")) + qt_app.save_project_as(str(tmp_path / "two.json")) + dialog = qt_app.show_welcome() + dialog.clear_recent() + assert qt_app.settings["recent_projects"] == [] + assert dialog.paths() == [qt_app.project_path] + + +def test_choose_opens_other_and_resume_is_noop(qt_app, tmp_path): + _type(qt_app.editor, "story text") + story = _save_as(qt_app, str(tmp_path / "story.tbaw")) + qt_app.new_project() + _save_as(qt_app, str(tmp_path / "other.tbaw")) + other_doc = qt_app.document + + dialog = qt_app.show_welcome() + dialog.choose(qt_app.project_path) + assert not dialog.isVisible() + assert qt_app.document is other_doc + + dialog = qt_app.show_welcome() + dialog.choose(story) + qt_app.wait_for_project_io() + assert qt_app.project_path == story + assert qt_app.editor.toPlainText() == "story text" + assert qt_app.windowTitle().startswith("story") + + +def test_new_project_inherits_characters(qt_app): + qt_app.document.characters.append(Character.from_preset_dict("Bob", {})) + dialog = qt_app.show_welcome() + dialog.new_project() + assert not dialog.isVisible() + assert qt_app.project_path is None + assert [c.name for c in qt_app.document.characters] == ["Default", "Bob"] + + +def test_new_from_text_starts_fresh_project_with_the_text(qt_app, tmp_path): + _type(qt_app.editor, "old") + qt_app.engine.extract_text_from_file.return_value = "Once upon a time." + src = tmp_path / "chapter.txt" + src.write_text("Once upon a time.", encoding="utf-8") + dialog = qt_app.show_welcome() + dialog.new_from_text(str(src)) + assert qt_app.project_path is None + assert qt_app.editor.toPlainText() == "Once upon a time." + + +def test_welcome_action_sits_in_file_menu(qt_app): + texts = [a.text() for a in qt_app.file_menu.actions()] + assert "&Welcome..." in texts + assert texts.index("&Welcome...") > texts.index("Recent") diff --git a/tests/test_asr.py b/tests/test_asr.py new file mode 100644 index 0000000..7d86def --- /dev/null +++ b/tests/test_asr.py @@ -0,0 +1,356 @@ +"""Tests for the auto-transcription helpers (kokoro_gui/engine/asr.py): the +default Audio8/Audio8-ASR-0.1B engine and the offline Vosk engine. Neither +ever touches its real model - `_get_model`/`_get_vosk_model` are monkeypatched +to fakes in every test that exercises `transcribe_wav`. +""" +import json +import os +import subprocess +import sys +import wave +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +import kokoro_gui.engine.asr as asr +from tests.conftest import strip_ansi + + +def _write_pcm16_mono_wav(path, n_frames=1600, framerate=16000): + with wave.open(str(path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(framerate) + wf.writeframes(b"\x00\x00" * n_frames) + + +class _FakeInputs(dict): + def __init__(self): + super().__init__(input_ids=_FakeTensor()) + + +class _FakeTensor: + shape = (1, 7) + + +class _FakeProcessor: + def apply_chat_template(self, conversation, **kwargs): + assert conversation[0]["content"][0]["type"] == "audio" + return _FakeInputs() + + def decode(self, token_ids, skip_special_tokens=True): + return " hello from the fake model " + + +class _FakeModel: + def generate(self, **kwargs): + return [[0] * 7 + [1, 2, 3]] # prompt tokens + 3 "generated" tokens + + +@pytest.fixture(autouse=True) +def _reset_singleton(monkeypatch): + # _get_model/_get_vosk_model each cache process-wide singletons - make + # sure one test's fake model doesn't leak into another's assertions + # about load behavior. + monkeypatch.setattr(asr, "_model", None) + monkeypatch.setattr(asr, "_processor", None) + monkeypatch.setattr(asr, "_vosk_models", {}) + + +def test_transcribe_wav_returns_stripped_decoded_text(monkeypatch, tmp_path): + monkeypatch.setattr(asr, "_get_model", lambda: (_FakeModel(), _FakeProcessor())) + + wav_path = str(tmp_path / "ref.wav") + result = asr.transcribe_wav(wav_path) + + assert result == "hello from the fake model" + + +def test_transcribe_wav_wraps_generation_errors(monkeypatch, tmp_path): + class _BoomModel: + def generate(self, **kwargs): + raise RuntimeError("out of memory") + + monkeypatch.setattr(asr, "_get_model", lambda: (_BoomModel(), _FakeProcessor())) + + with pytest.raises(RuntimeError, match="Transcription failed"): + asr.transcribe_wav(str(tmp_path / "ref.wav")) + + +def test_get_model_raises_clear_error_without_transformers(monkeypatch): + import builtins + real_import = builtins.__import__ + + def _no_transformers(name, *args, **kwargs): + if name == "transformers": + raise ImportError("no module named transformers") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _no_transformers) + + with pytest.raises(RuntimeError, match="transformers"): + asr._get_model() + + +def test_importing_module_does_not_load_the_model(): + """Importing this module (which happens whenever the Voice Reference + dock is built) must not trigger `AutoModel.from_pretrained`/download - + only calling `transcribe_wav` (or `_get_model`) does. (`transformers` + itself is already an indirect hard dependency via the `kokoro` package, + so the meaningful guarantee here is "no model load", not "no + transformers import" - see this module's docstring.) Checked in a fresh + interpreter, importing `kokoro_engine` first to match real app startup + order (kokoro_gui/engine/__init__.py's own transitive import chain back + to kokoro_engine.py means importing any of its submodules cold, without + kokoro_engine already in sys.modules, hits an unrelated pre-existing + circular-import ordering requirement).""" + code = ( + "import kokoro_engine, kokoro_gui.engine.asr as asr; " + "print(asr._model is None and asr._processor is None)" + ) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, + cwd=str(Path(__file__).resolve().parent.parent)) + assert result.returncode == 0, result.stderr + assert strip_ansi(result.stdout).strip() == "True" + + +# --- Vosk engine ---------------------------------------------------------- + +class _FakeRecognizer: + """First `AcceptWaveform` call reports a finalized chunk ("hello"); every + call after that reports "still listening" (mirrors Vosk's real streaming + behavior enough to exercise the accumulate-then-join loop).""" + + def __init__(self, model, rate): + self.model = model + self.rate = rate + self._calls = 0 + + def SetWords(self, words): + pass + + def AcceptWaveform(self, data): + self._calls += 1 + return self._calls == 1 + + def Result(self): + return json.dumps({"text": "hello"}) + + def FinalResult(self): + return json.dumps({"text": "world"}) + + +class _FakeVoskModule: + KaldiRecognizer = _FakeRecognizer + + class Model: + def __init__(self, path): + # The real `vosk.Model` raises this for a folder it can't load, + # a missing one included. + if not os.path.isdir(path): + raise Exception("Failed to create a model") + self.path = path + + @staticmethod + def SetLogLevel(level): + pass + + +def test_transcribe_wav_vosk_joins_recognizer_output(monkeypatch, tmp_path): + wav_path = tmp_path / "ref.wav" + _write_pcm16_mono_wav(wav_path, n_frames=8000) # >4000 frames -> AcceptWaveform called twice + + monkeypatch.setitem(sys.modules, "vosk", _FakeVoskModule()) + monkeypatch.setattr(asr, "_get_vosk_model", lambda path: object()) + + result = asr.transcribe_wav(str(wav_path), engine="vosk", model_path=str(tmp_path)) + + assert result == "hello world" + + +def test_transcribe_wav_vosk_converts_stereo_wav_before_transcribing(monkeypatch, tmp_path): + wav_path = tmp_path / "stereo.wav" + with wave.open(str(wav_path), "wb") as wf: + wf.setnchannels(2) + wf.setsampwidth(2) + wf.setframerate(16000) + wf.writeframes(b"\x00\x00\x00\x00" * 100) + + monkeypatch.setitem(sys.modules, "vosk", _FakeVoskModule()) + monkeypatch.setattr(asr, "_get_vosk_model", lambda path: object()) + + result = asr.transcribe_wav(str(wav_path), engine="vosk", model_path=str(tmp_path)) + + assert result == "hello world" + + +def test_transcribe_wav_vosk_cleans_up_the_converted_temp_file(monkeypatch, tmp_path): + wav_path = tmp_path / "ref.wav" + _write_pcm16_mono_wav(wav_path, n_frames=8000) + converted_path = tmp_path / "converted.wav" + _write_pcm16_mono_wav(converted_path, n_frames=8000) + + monkeypatch.setattr(asr, "_ensure_pcm16_mono", lambda path: (str(converted_path), str(converted_path))) + monkeypatch.setitem(sys.modules, "vosk", _FakeVoskModule()) + monkeypatch.setattr(asr, "_get_vosk_model", lambda path: object()) + + asr.transcribe_wav(str(wav_path), engine="vosk", model_path=str(tmp_path)) + + assert not converted_path.exists() + + +def test_get_vosk_model_raises_clear_error_without_vosk_package(monkeypatch, tmp_path): + import builtins + real_import = builtins.__import__ + + def _no_vosk(name, *args, **kwargs): + if name == "vosk": + raise ImportError("no module named vosk") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _no_vosk) + + with pytest.raises(RuntimeError, match="vosk"): + asr._get_vosk_model(str(tmp_path)) + + +def test_get_vosk_model_requires_a_model_path(monkeypatch): + monkeypatch.setitem(sys.modules, "vosk", _FakeVoskModule()) + + with pytest.raises(RuntimeError, match="model folder"): + asr._get_vosk_model("") + + +def test_get_vosk_model_names_the_folder_when_vosk_rejects_it(monkeypatch, tmp_path): + """The real `vosk.Model` raises for a missing folder and for one without + model files alike; the error names the path and what belongs there.""" + monkeypatch.setitem(sys.modules, "vosk", _FakeVoskModule()) + missing = str(tmp_path / "does-not-exist") + + with pytest.raises(RuntimeError, match="does-not-exist.*alphacephei"): + asr._get_vosk_model(missing) + + +def test_ensure_pcm16_mono_passes_already_correct_wav_through_unchanged(tmp_path): + wav_path = tmp_path / "ref.wav" + _write_pcm16_mono_wav(wav_path) + + result_path, temp_path = asr._ensure_pcm16_mono(str(wav_path)) + + assert result_path == str(wav_path) + assert temp_path is None + + +def test_ensure_pcm16_mono_downmixes_stereo(tmp_path): + wav_path = tmp_path / "stereo.wav" + with wave.open(str(wav_path), "wb") as wf: + wf.setnchannels(2) + wf.setsampwidth(2) + wf.setframerate(16000) + wf.writeframes(b"\x00\x00\x00\x00" * 100) + + result_path, temp_path = asr._ensure_pcm16_mono(str(wav_path)) + try: + assert temp_path == result_path + assert result_path != str(wav_path) + with wave.open(result_path, "rb") as wf: + assert wf.getnchannels() == 1 + assert wf.getsampwidth() == 2 + finally: + os.remove(result_path) + + +def test_ensure_pcm16_mono_converts_float_samples(tmp_path): + wav_path = tmp_path / "float.wav" + sf.write(str(wav_path), np.zeros(100, dtype=np.float32), 16000, subtype="FLOAT") + + result_path, temp_path = asr._ensure_pcm16_mono(str(wav_path)) + try: + assert temp_path == result_path + with wave.open(result_path, "rb") as wf: + assert wf.getnchannels() == 1 + assert wf.getsampwidth() == 2 + finally: + os.remove(result_path) + + +# --- VOSK_MODEL_PATH (env-configured, not a GUI setting) ------------------- + +def test_get_vosk_model_path_reads_and_strips_env_var(monkeypatch): + monkeypatch.setenv("VOSK_MODEL_PATH", " /some/model/dir ") + assert asr.get_vosk_model_path() == "/some/model/dir" + + +def test_get_vosk_model_path_defaults_to_empty_when_unset(monkeypatch): + monkeypatch.delenv("VOSK_MODEL_PATH", raising=False) + assert asr.get_vosk_model_path() == "" + + +def test_reload_vosk_model_path_rereads_dotenv_from_cwd(monkeypatch, tmp_path): + monkeypatch.delenv("VOSK_MODEL_PATH", raising=False) + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text("VOSK_MODEL_PATH=/from/dotenv\n") + + result = asr.reload_vosk_model_path() + + assert result == "/from/dotenv" + assert os.environ["VOSK_MODEL_PATH"] == "/from/dotenv" + + +def test_reload_vosk_model_path_overrides_a_stale_value(monkeypatch, tmp_path): + monkeypatch.setenv("VOSK_MODEL_PATH", "/stale") + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text("VOSK_MODEL_PATH=/fresh\n") + + assert asr.reload_vosk_model_path() == "/fresh" + + +def test_set_vosk_model_path_creates_dotenv_when_none_exists(monkeypatch, tmp_path): + monkeypatch.delenv("VOSK_MODEL_PATH", raising=False) + monkeypatch.chdir(tmp_path) + assert not (tmp_path / ".env").exists() + + asr.set_vosk_model_path(str(tmp_path / "my-model")) + + assert os.environ["VOSK_MODEL_PATH"] == str(tmp_path / "my-model") + assert "my-model" in (tmp_path / ".env").read_text() + + +def test_set_vosk_model_path_updates_an_existing_dotenv_in_place(monkeypatch, tmp_path): + monkeypatch.delenv("VOSK_MODEL_PATH", raising=False) + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text("SOME_OTHER_KEY=keep-me\nVOSK_MODEL_PATH=/old\n") + + asr.set_vosk_model_path("/new") + + contents = (tmp_path / ".env").read_text() + assert "SOME_OTHER_KEY=keep-me" in contents + assert "/new" in contents + assert "/old" not in contents + assert os.environ["VOSK_MODEL_PATH"] == "/new" + + +def test_set_vosk_model_path_strips_whitespace(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + + asr.set_vosk_model_path(" /padded/path ") + + assert os.environ["VOSK_MODEL_PATH"] == "/padded/path" + assert asr.get_vosk_model_path() == "/padded/path" + + +# --- engine registry / dispatch -------------------------------------------- + +def test_transcribe_wav_rejects_unknown_engine(tmp_path): + with pytest.raises(ValueError, match="Unknown ASR engine"): + asr.transcribe_wav(str(tmp_path / "ref.wav"), engine="nope") + + +def test_get_asr_engine_roundtrip(): + info = asr.get_asr_engine("vosk") + assert info.id == "vosk" + + with pytest.raises(ValueError, match="Unknown ASR engine"): + asr.get_asr_engine("nope") diff --git a/tests/test_batch_conversion.py b/tests/test_batch_conversion.py index 6e501c1..bae5bc2 100644 --- a/tests/test_batch_conversion.py +++ b/tests/test_batch_conversion.py @@ -4,6 +4,9 @@ import asyncio import json +import kokoro_engine +from kokoro_gui.engine import stats as generation_stats + def test_process_chunk_task_writes_named_part_files(engine, fake_pipeline, make_config, isolated_dirs): config = make_config(filename="myrun", time_id="20260101000000") @@ -100,6 +103,57 @@ def spy(chunk_data, progress_callback): assert captured["config"]["apply_fx"] is True +def test_process_text_async_records_generation_stats_under_engine_id(engine, fake_pipeline, make_config, isolated_dirs): + text = "Hello there, this is a short test sentence." + config = make_config(engine_id="kokoro", filename="run", time_id="1") + asyncio.run(engine._process_text_async(text, config)) + + with open(kokoro_engine.STATS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + entry = data["kokoro"][-1] + assert entry["chars"] == len(text) + assert entry["words"] == len(text.split()) + assert entry["duration"] > 0 + + +def test_process_text_async_keeps_stats_separate_per_engine_id(engine, fake_pipeline, make_config, isolated_dirs): + text_a = "Text for engine one." + text_b = "A distinctly longer piece of text used for engine two." + asyncio.run(engine._process_text_async(text_a, make_config(engine_id="engine-a", time_id="1"))) + asyncio.run(engine._process_text_async(text_b, make_config(engine_id="engine-b", time_id="2"))) + + with open(kokoro_engine.STATS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + assert data["engine-a"][-1]["chars"] == len(text_a) + assert data["engine-b"][-1]["chars"] == len(text_b) + + +def test_process_text_async_seeds_initial_eta_from_history(engine, fake_pipeline, make_config, isolated_dirs, callback_recorder): + # Prime history for this engine_id well before the run starts, so the + # very first on_progress call (percent 0) already carries a real ETA + # instead of "--:--". + generation_stats.record_generation("kokoro", chars=1000, words=180, duration=10.0) + + config = make_config(engine_id="kokoro", filename="run", time_id="1") + asyncio.run(engine._process_text_async("Hello there, this is a short test sentence.", config)) + + first_percent, first_elapsed, first_eta, first_detail = callback_recorder.progresses[0] + assert first_percent == 0 + assert first_elapsed == 0.0 + assert first_eta != "--:--" + + +def test_process_text_async_no_history_still_shows_no_initial_eta(engine, fake_pipeline, make_config, isolated_dirs, callback_recorder): + config = make_config(engine_id="brand-new-engine", filename="run", time_id="1") + asyncio.run(engine._process_text_async("Hello there, this is a short test sentence.", config)) + + # Without history, the first callback is a real chunk-progress tick + # (not the history-seeded pre-generation one), so it should carry actual + # progress rather than the percent==0 placeholder. + first_percent, *_ = callback_recorder.progresses[0] + assert first_percent > 0 + + def test_full_batch_conversion_leaves_inspectable_output(engine, fake_pipeline, make_config, timestamped_output_dir): config = make_config(out_dir=str(timestamped_output_dir), filename="sample", time_id="smoke") asyncio.run(engine._process_text_async("This audio should be inspectable by a human.", config)) diff --git a/tests/test_caching.py b/tests/test_caching.py index dc69309..4aac75f 100644 --- a/tests/test_caching.py +++ b/tests/test_caching.py @@ -1,9 +1,11 @@ -"""Tests for process_chunk_task's caching logic (kokoro_engine.py:568-705). +"""Tests for process_chunk_task's caching logic (kokoro_gui/engine/caching.py) +and the compute_cache_key helper it's built on +(PLAN_qt_and_engine_abstraction.md workstream 2). This is the ONLY test module allowed to pass caching=True - see tests/test_meta_caching_policy.py for the enforced guard. """ -import hashlib +import asyncio import os import numpy as np @@ -11,17 +13,25 @@ import soundfile as sf import kokoro_engine +from kokoro_gui.engine.caching import CACHE_SCHEMA_VERSION, compute_cache_key +from kokoro_gui.engines import audio8_tts +from kokoro_gui.engines.audio8_tts import Audio8Engine, Audio8ReferenceStore -def _hash(text, voice, speed, lang_code): - return hashlib.md5(f"{text}|{voice}|{speed}|{lang_code}".encode("utf-8")).hexdigest() +def _hash(text, config, eff_speed=None, lang_code=None, engine_id="kokoro"): + return compute_cache_key( + text, config["voice"], + eff_speed if eff_speed is not None else config["speed"], + lang_code if lang_code is not None else config["lang_code"], + engine_id, + ) def test_cache_miss_writes_raw_pre_fx_audio(engine, fake_pipeline, isolated_dirs, make_config): config = make_config(caching=True, volume=0.5) results = engine.process_chunk_task((0, "Hello world.", config), None) - h = _hash("Hello world.", config["voice"], config["speed"], config["lang_code"]) + h = _hash("Hello world.", config) cache_file = isolated_dirs.cache_dir / f"{h}_0.wav" assert cache_file.exists() @@ -36,7 +46,7 @@ def test_cache_miss_writes_raw_pre_fx_audio(engine, fake_pipeline, isolated_dirs def test_cache_hit_skips_pipeline_call(engine, isolated_dirs, make_config, monkeypatch): config = make_config(caching=True) text = "Hello world." - h = _hash(text, config["voice"], config["speed"], config["lang_code"]) + h = _hash(text, config) audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1200) / 24000)).astype(np.float32) sf.write(str(isolated_dirs.cache_dir / f"{h}_0.wav"), audio, 24000) @@ -52,6 +62,29 @@ def _boom(lang_code="a"): assert os.path.exists(results[0]["path"]) +def test_generate_clip_audio_cache_hits_like_batch_path(engine, isolated_dirs, make_config, monkeypatch): + # kokoro_gui/engine/conversion.py's generate_clip_audio (the per-clip + # Generate entry point - Claude/PLAN_daw_ui_ux_redesign.md) is a thin + # asyncio.to_thread wrapper around process_chunk_task; confirms it + # doesn't interfere with that method's own already-tested cache-hit path. + config = make_config(caching=True) + text = "Hello world." + h = _hash(text, config) + + audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1200) / 24000)).astype(np.float32) + sf.write(str(isolated_dirs.cache_dir / f"{h}_0.wav"), audio, 24000) + + def _boom(lang_code="a"): + raise AssertionError("pipeline should not be called on a cache hit") + + monkeypatch.setattr(kokoro_engine, "get_thread_pipeline", _boom) + + results = asyncio.run(engine.generate_clip_audio((0, text, config))) + + assert len(results) == 1 + assert os.path.exists(results[0]["path"]) + + def test_cache_key_ignores_split_pattern(engine, fake_pipeline, make_config, monkeypatch): text = "Hello world." config1 = make_config(caching=True, split_pattern=r"\n+") @@ -74,7 +107,7 @@ def _boom(lang_code="a"): def test_cache_partial_files_missing_forces_regeneration(engine, fake_pipeline, isolated_dirs, make_config): text = "Seg one.\n\nSeg two." config = make_config(caching=True) - h = _hash(text, config["voice"], config["speed"], config["lang_code"]) + h = _hash(text, config) # Only the first of the two expected segments is cached. audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1200) / 24000)).astype(np.float32) @@ -109,3 +142,414 @@ def test_speed_affects_cache_key(engine, fake_pipeline, isolated_dirs, make_conf cache_files = list(isolated_dirs.cache_dir.glob("*_0.wav")) assert len(cache_files) == 2 + + +# --- Workstream 2 hardening: compute_cache_key in isolation ----------------- + +def test_compute_cache_key_is_deterministic_and_sha256(): + h1 = compute_cache_key("Hello.", "af_heart", 1.0, "a") + h2 = compute_cache_key("Hello.", "af_heart", 1.0, "a") + + assert h1 == h2 + assert len(h1) == 64 # sha256 hex digest, not md5's 32 + assert all(c in "0123456789abcdef" for c in h1) + + +def test_compute_cache_key_takes_only_what_it_needs(): + # Not a whole config dict - just the inputs that actually determine a + # segment's content. out_dir/filename/format/normalize/trim/the FX + # chain/num_threads/etc. never even get a chance to leak into the hash, + # because the function has nowhere to read them from. `extra` is the one + # deliberate escape hatch - see test_compute_cache_key_extra_* below. + import inspect + + params = list(inspect.signature(compute_cache_key).parameters) + assert params == ["text", "voice", "eff_speed", "lang_code", "engine_id", "engine_version", "extra", + "schema_version", "voice_fingerprint_value"] + + +def test_compute_cache_key_differs_by_each_input(): + base = compute_cache_key("Hello.", "af_heart", 1.0, "a") + + assert compute_cache_key("Goodbye.", "af_heart", 1.0, "a") != base + assert compute_cache_key("Hello.", "af_bella", 1.0, "a") != base + assert compute_cache_key("Hello.", "af_heart", 1.5, "a") != base + assert compute_cache_key("Hello.", "af_heart", 1.0, "b") != base + assert compute_cache_key("Hello.", "af_heart", 1.0, "a", engine_id="dummy") != base + assert compute_cache_key("Hello.", "af_heart", 1.0, "a", engine_version="1.2.3") != \ + compute_cache_key("Hello.", "af_heart", 1.0, "a", engine_version="1.2.4") + + +# --- Workstream 2 hardening: cache invalidation through process_chunk_task -- + +def test_custom_voice_content_change_invalidates_cache(engine, fake_pipeline, isolated_dirs, make_config): + """Remixing and re-saving a custom voice under the same name (a real + workflow - VoiceMixingMixin.mix_voices) must invalidate old cache + entries for that name, since the name alone no longer identifies what + was actually generated.""" + voice_path = isolated_dirs.custom_voices / "MyMix.pt" + voice_path.write_bytes(b"tensor-content-v1") + resolved = engine.resolve_voice_path("MyMix") + + text = "Hello world." + config = make_config(caching=True, voice=resolved) + + engine.process_chunk_task((0, text, config), None) + files_v1 = set(isolated_dirs.cache_dir.glob("*_0.wav")) + assert len(files_v1) == 1 + + # Re-save under the same path/name with different content - and force a + # different mtime so the in-memory fingerprint cache can't coast on a + # coarse filesystem timestamp resolution masking the change. + voice_path.write_bytes(b"tensor-content-v2-longer-and-different") + future = os.path.getmtime(voice_path) + 5 + os.utime(voice_path, (future, future)) + + engine.process_chunk_task((0, text, config), None) + files_v2 = set(isolated_dirs.cache_dir.glob("*_0.wav")) + + assert len(files_v2) == 2 + assert files_v1 < files_v2 # old entry untouched, a new one was added + + +def test_engine_id_change_invalidates_cache(engine, fake_pipeline, isolated_dirs, make_config): + text = "Hello world." + config_a = make_config(caching=True, engine_id="kokoro") + config_b = make_config(caching=True, engine_id="some-other-engine") + + engine.process_chunk_task((0, text, config_a), None) + engine.process_chunk_task((0, text, config_b), None) + + cache_files = list(isolated_dirs.cache_dir.glob("*_0.wav")) + assert len(cache_files) == 2 + + +def test_engine_version_change_invalidates_cache(engine, fake_pipeline, isolated_dirs, make_config, monkeypatch): + import kokoro_gui.engine.caching as caching_mod + + text = "Hello world." + config = make_config(caching=True) + + monkeypatch.setattr(caching_mod, "get_engine_version", lambda engine_id="kokoro": "1.0.0") + engine.process_chunk_task((0, text, config), None) + + monkeypatch.setattr(caching_mod, "get_engine_version", lambda engine_id="kokoro": "2.0.0") + engine.process_chunk_task((0, text, config), None) + + cache_files = list(isolated_dirs.cache_dir.glob("*_0.wav")) + assert len(cache_files) == 2 + + +def test_compute_cache_key_extra_none_matches_no_extra_arg(): + """`extra` is additive - omitting it entirely and passing `extra=None` + must hash identically, and both must match what the function produced + before `extra` existed (Kokoro's/Dummy's call sites never pass it).""" + without_arg = compute_cache_key("Hello.", "af_heart", 1.0, "a") + with_none = compute_cache_key("Hello.", "af_heart", 1.0, "a", extra=None) + with_empty = compute_cache_key("Hello.", "af_heart", 1.0, "a", extra={}) + assert without_arg == with_none == with_empty + + +def test_compute_cache_key_extra_dict_changes_hash(): + """Audio8Engine folds a reference transcript into `extra` so editing the + transcript for the same reference wav (same name, same content hash) + still invalidates the cache - see kokoro_gui/engines/audio8_tts.py's + process_chunk_task.""" + base = compute_cache_key("Hello.", "/refs/alice.wav", 1.0, "English", engine_id="audio8", + extra={"ref_transcript": "Hi there."}) + changed = compute_cache_key("Hello.", "/refs/alice.wav", 1.0, "English", engine_id="audio8", + extra={"ref_transcript": "Hi there!"}) + same = compute_cache_key("Hello.", "/refs/alice.wav", 1.0, "English", engine_id="audio8", + extra={"ref_transcript": "Hi there."}) + assert base != changed + assert base == same + + +def test_schema_version_bump_invalidates_cache(engine, fake_pipeline, isolated_dirs, make_config, monkeypatch): + import kokoro_gui.engine.caching as caching_mod + + text = "Hello world." + config = make_config(caching=True) + + engine.process_chunk_task((0, text, config), None) + + monkeypatch.setattr(caching_mod, "CACHE_SCHEMA_VERSION", CACHE_SCHEMA_VERSION + 1) + engine.process_chunk_task((0, text, config), None) + + cache_files = list(isolated_dirs.cache_dir.glob("*_0.wav")) + assert len(cache_files) == 2 + + +# --- Audio8Engine: its own hand-rolled caching (kokoro_gui/engines/audio8_tts.py) -- +# +# Audio8Engine doesn't use CachingMixin (see that module's docstring - 24000Hz +# hardcoding vs. its own 44100Hz), but still participates in the same +# CACHE_DIR via compute_cache_key directly, with a reference transcript +# folded in through the new `extra` parameter above. + +def test_audio8_process_chunk_task_caching_keys_on_transcript(isolated_dirs, tmp_path, monkeypatch): + """Same reference wav, different transcript (the 'auto-transcript was + wrong, I fixed it' workflow) must be treated as a cache miss.""" + import numpy as np + + monkeypatch.setattr(audio8_tts, "AUDIO8_REFS_DIR", str(tmp_path / "audio8_refs")) + + wav_path = tmp_path / "ref.wav" + ref_audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1600) / 16000)).astype(np.float32) + sf.write(str(wav_path), ref_audio, 16000) + Audio8ReferenceStore.save_reference("Eve", str(wav_path), "Original transcript.") + + engine = Audio8Engine() + try: + def _fake_segment(text, ref_wav_path, ref_transcript, speed, lang_code): + t = np.arange(2200) / 44100 + return (0.1 * np.sin(2 * np.pi * 220 * t)).astype(np.float32) + monkeypatch.setattr(engine, "generate_segment", _fake_segment) + + voice_path = engine.resolve_voice_path("Eve") + config = { + "lang_code": "English", "voice": voice_path, "speed": 1.0, "split_pattern": r"\n+", + "filename": "out", "time_id": "1", "out_dir": str(isolated_dirs.out_dir), + "format": "wav", "caching": True, "apply_fx": False, + } + engine.process_chunk_task((0, "Hello there.", config), None) + first_cache_files = set(isolated_dirs.cache_dir.glob("*_0.wav")) + assert len(first_cache_files) == 1 + + # Re-save the same name with a different transcript (same wav content). + Audio8ReferenceStore.save_reference("Eve", str(wav_path), "Corrected transcript!") + engine.process_chunk_task((0, "Hello there.", config), None) + second_cache_files = set(isolated_dirs.cache_dir.glob("*_0.wav")) + + assert len(second_cache_files) == 2 + assert first_cache_files < second_cache_files + finally: + engine.worker.stop() + + +def test_audio8_process_chunk_task_caching_keys_on_sampling_knobs(isolated_dirs, tmp_path, monkeypatch): + """Changing a sampling knob (temperature/top_p/top_k/max_new_tokens) + changes what the model would generate, so it must be a cache miss too - + same reasoning as the transcript test above, folded into `extra` the + same way.""" + import numpy as np + + monkeypatch.setattr(audio8_tts, "AUDIO8_REFS_DIR", str(tmp_path / "audio8_refs")) + + wav_path = tmp_path / "ref.wav" + ref_audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1600) / 16000)).astype(np.float32) + sf.write(str(wav_path), ref_audio, 16000) + Audio8ReferenceStore.save_reference("Faye", str(wav_path), "Faye's reference line.") + + engine = Audio8Engine() + try: + def _fake_segment(text, ref_wav_path, ref_transcript, speed, lang_code): + t = np.arange(2200) / 44100 + return (0.1 * np.sin(2 * np.pi * 220 * t)).astype(np.float32) + monkeypatch.setattr(engine, "generate_segment", _fake_segment) + + voice_path = engine.resolve_voice_path("Faye") + config = { + "lang_code": "English", "voice": voice_path, "speed": 1.0, "split_pattern": r"\n+", + "filename": "out", "time_id": "1", "out_dir": str(isolated_dirs.out_dir), + "format": "wav", "caching": True, "apply_fx": False, "temperature": 0.8, + } + engine.process_chunk_task((0, "Hello there.", config), None) + first_cache_files = set(isolated_dirs.cache_dir.glob("*_0.wav")) + assert len(first_cache_files) == 1 + + config["temperature"] = 1.2 # only the sampling knob changes + engine.process_chunk_task((0, "Hello there.", config), None) + second_cache_files = set(isolated_dirs.cache_dir.glob("*_0.wav")) + + assert len(second_cache_files) == 2 + assert first_cache_files < second_cache_files + finally: + engine.worker.stop() + + +def test_audio8_process_chunk_task_caches_every_segment_in_a_multi_segment_chunk(isolated_dirs, tmp_path, monkeypatch): + """A chunk that splits into more than one segment (split_pattern + matching within one chunk's text, e.g. two newline-separated lines) must + cache/read *every* segment - not just the first - on both the write and + the cache-hit path.""" + import numpy as np + + monkeypatch.setattr(audio8_tts, "AUDIO8_REFS_DIR", str(tmp_path / "audio8_refs")) + + wav_path = tmp_path / "ref.wav" + ref_audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1600) / 16000)).astype(np.float32) + sf.write(str(wav_path), ref_audio, 16000) + Audio8ReferenceStore.save_reference("Zoe", str(wav_path), "Zoe's reference line.") + + engine = Audio8Engine() + try: + def _fake_segment(text, ref_wav_path, ref_transcript, speed, lang_code): + t = np.arange(2200) / 44100 + return (0.1 * np.sin(2 * np.pi * 220 * t)).astype(np.float32) + monkeypatch.setattr(engine, "generate_segment", _fake_segment) + + voice_path = engine.resolve_voice_path("Zoe") + config = { + "lang_code": "English", "voice": voice_path, "speed": 1.0, "split_pattern": r"\n+", + "filename": "out", "time_id": "1", "out_dir": str(isolated_dirs.out_dir), + "format": "wav", "caching": True, "apply_fx": False, + } + text = "Segment one.\nSegment two." + + files = engine.process_chunk_task((0, text, config), None) + assert len(files) == 2 + cache_files = set(isolated_dirs.cache_dir.glob("*.wav")) + assert len(cache_files) == 2 # _0.wav and _1.wav + + # Cache hit path must also produce both segments, not just the first. + def _boom(*a, **k): + raise AssertionError("generate_segment should not be called on a cache hit") + monkeypatch.setattr(engine, "generate_segment", _boom) + + files_hit = engine.process_chunk_task((0, text, config), None) + assert len(files_hit) == 2 + finally: + engine.worker.stop() + + +# --- segment_naming: "cache_key" (Claude/old/PLAN_tbaw_bundle.md sections 2.3, 3) --- +# +# Every clip generation runs in this mode: `out_dir` is the cache, the file +# is named by the segment key, `CACHE_DIR` is untouched, and a present file +# is never overwritten - the take bumps instead (TB8). + +def _project_config(make_config, project_dir, **overrides): + return make_config(out_dir=str(project_dir), segment_naming="cache_key", raw_output=True, **overrides) + + +def test_cache_key_naming_writes_one_file_named_by_the_key_and_nothing_in_cache_dir( + engine, fake_pipeline, isolated_dirs, make_config, tmp_path): + from kokoro_gui.engine.caching import segment_key + + project = tmp_path / "audio" + project.mkdir() + config = _project_config(make_config, project) + + results = engine.process_chunk_task((0, "Hello world.", config), None) + + key = segment_key("Hello world.", config, engine) + assert len(results) == 1 + assert results[0]["path"] == os.path.join(str(project), f"{key}_0.wav") + assert results[0]["cache_key"] == key + assert results[0]["take"] == 0 + assert results[0]["engine_version"] == engine.engine_version() + assert results[0]["raw"] is True + assert sorted(os.listdir(project)) == [f"{key}_0.wav"] + assert list(isolated_dirs.cache_dir.iterdir()) == [] + + +def test_cache_key_naming_hit_returns_the_present_files_without_a_write( + engine, fake_pipeline, isolated_dirs, make_config, tmp_path, monkeypatch): + project = tmp_path / "audio" + project.mkdir() + config = _project_config(make_config, project) + first = engine.process_chunk_task((0, "Hello world.", config), None) + mtime = os.path.getmtime(first[0]["path"]) + + def _boom(lang_code="a"): + raise AssertionError("pipeline should not be called on a hit") + + monkeypatch.setattr(kokoro_engine, "get_thread_pipeline", _boom) + second = engine.process_chunk_task((0, "Hello world.", config), None) + + assert [r["path"] for r in second] == [r["path"] for r in first] + assert os.path.getmtime(second[0]["path"]) == mtime + assert second[0]["duration"] == pytest.approx(first[0]["duration"], abs=1e-3) + + +def test_regenerate_bumps_the_take_and_leaves_the_present_file_alone( + engine, fake_pipeline, isolated_dirs, make_config, tmp_path): + project = tmp_path / "audio" + project.mkdir() + config = _project_config(make_config, project) + first = engine.process_chunk_task((0, "Hello world.", config), None) + before = open(first[0]["path"], "rb").read() + + second = engine.process_chunk_task((0, "Hello world.", {**config, "regenerate": True}), None) + + assert second[0]["take"] == 1 + assert second[0]["path"] != first[0]["path"] + assert second[0]["cache_key"] != first[0]["cache_key"] + assert open(first[0]["path"], "rb").read() == before + assert len(os.listdir(project)) == 2 + + # The dirty path for the bumped clip (take 1, no regenerate) is a hit. + third = engine.process_chunk_task((0, "Hello world.", {**config, "take": 1}), None) + assert third[0]["path"] == second[0]["path"] + assert len(os.listdir(project)) == 2 + + +def test_two_identical_clips_in_one_batch_never_clobber_each_other( + engine, fake_pipeline, isolated_dirs, make_config, tmp_path, monkeypatch): + """With num_threads=2 both clips see the file absent at the same time. + The `O_EXCL` reservation makes the second one land on take 1 (or, when + the first finishes before the second checks, share the file); either + way nothing writes over a file another clip references.""" + import threading + + project = tmp_path / "audio" + project.mkdir() + config = _project_config(make_config, project, num_threads=2) + + gate = threading.Barrier(2, timeout=10) + real_pipeline = fake_pipeline + + class _SlowPipeline: + def __call__(self, *args, **kwargs): + gate.wait() # both clips are past the hit check before either writes + yield from real_pipeline(*args, **kwargs) + + lang_code = "a" + + monkeypatch.setattr(kokoro_engine, "get_thread_pipeline", lambda lang_code="a": _SlowPipeline()) + + outcomes = asyncio.run(engine.generate_dirty_clips([ + ("clip-a", "Hello world.", config), ("clip-b", "Hello world.", config), + ])) + + assert all(o["success"] for o in outcomes) + paths = {o["results"][0]["path"] for o in outcomes} + takes = sorted(o["results"][0]["take"] for o in outcomes) + assert takes == [0, 1] + assert len(paths) == 2 + assert all(os.path.getsize(p) > 100 for p in paths) + assert not any(f.endswith(".reserved") for f in os.listdir(project)) + + +def test_audio8_cache_key_naming_names_files_by_the_shared_key(isolated_dirs, tmp_path, monkeypatch): + """Audio8's key folds the transcript in through `cache_key_extra`; the + file stem is that same key (one function for both, section 2.3).""" + import numpy as np + + from kokoro_gui.engine.caching import segment_key + + monkeypatch.setattr(audio8_tts, "AUDIO8_REFS_DIR", str(tmp_path / "audio8_refs")) + wav_path = tmp_path / "ref.wav" + sf.write(str(wav_path), (0.1 * np.sin(np.arange(1600) / 10)).astype(np.float32), 16000) + Audio8ReferenceStore.save_reference("Eve", str(wav_path), "Original transcript.") + project = tmp_path / "audio" + project.mkdir() + + engine = Audio8Engine() + try: + monkeypatch.setattr(engine, "generate_segment", + lambda *a, **k: (0.1 * np.sin(np.arange(2200) / 10)).astype(np.float32)) + config = { + "lang_code": "English", "voice": "Eve", "speed": 1.0, "split_pattern": r"\n+", + "out_dir": str(project), "format": "wav", "segment_naming": "cache_key", "engine_id": "audio8", + } + results = engine.process_chunk_task((0, "Hello there.", {**config, "voice": engine.resolve_voice_path("Eve")}), None) + key = segment_key("Hello there.", config, engine) + assert os.path.basename(results[0]["path"]) == f"{key}_0.wav" + assert results[0]["engine_version"] == audio8_tts.TTS_MODEL_ID + + Audio8ReferenceStore.save_reference("Eve", str(wav_path), "Corrected transcript!") + assert segment_key("Hello there.", config, engine) != key + finally: + engine.worker.stop() diff --git a/tests/test_engine_backend.py b/tests/test_engine_backend.py new file mode 100644 index 0000000..859fffd --- /dev/null +++ b/tests/test_engine_backend.py @@ -0,0 +1,136 @@ +"""Tests for the engine backend abstraction (kokoro_gui/engines/) - +PLAN_qt_and_engine_abstraction.md workstream 1.""" +import asyncio +from unittest.mock import MagicMock + +import pytest + +from kokoro_gui.engines import registry +from kokoro_gui.engines.base import ConfigField, EngineCapabilities, VoiceInfo +from kokoro_gui.engines.dummy import DummyBackendAdapter, DummyEngine +from kokoro_gui.engines.kokoro import KokoroBackendAdapter, OUTPUT_FORMAT_CHOICES, SPLIT_PATTERN_CHOICES + + +def test_kokoro_registered_by_default(): + assert "kokoro" in registry.list_engines() + + +def test_get_engine_wraps_the_given_engine_instance(engine): + backend = registry.get_engine("kokoro", engine=engine) + assert isinstance(backend, KokoroBackendAdapter) + assert backend._engine is engine + + +def test_get_engine_unknown_id_raises(): + with pytest.raises(KeyError): + registry.get_engine("does-not-exist") + + +def test_capabilities_reflect_kokoro_shape(): + caps = KokoroBackendAdapter.capabilities + assert isinstance(caps, EngineCapabilities) + assert caps.supports_voice_mixing is True + assert caps.supports_multi_speaker_script is True + assert caps.is_local_model is True + assert caps.supports_jit_streaming is True + + +def test_config_schema_covers_todays_actual_fields(engine): + backend = registry.get_engine("kokoro", engine=engine) + schema = backend.get_config_schema() + assert all(isinstance(f, ConfigField) for f in schema) + + keys = {f.key for f in schema} + assert keys == { + "lang_code", "voice", "speed", "pitch", "split_pattern", + "format", "num_threads", "caching", "lexicon", + } + + +def test_config_schema_split_pattern_and_format_choices(engine): + backend = registry.get_engine("kokoro", engine=engine) + by_key = {f.key: f for f in backend.get_config_schema()} + + assert by_key["split_pattern"].choices == SPLIT_PATTERN_CHOICES + assert by_key["format"].choices == OUTPUT_FORMAT_CHOICES + # voice/lang_code are GUI-resolved (dynamic), not schema-fixed. + assert by_key["voice"].choices is None + assert by_key["lang_code"].choices is None + + +def test_get_voices_empty_when_no_custom_voices(engine, isolated_dirs): + backend = registry.get_engine("kokoro", engine=engine) + assert backend.get_voices() == [] + + +def test_get_voices_lists_custom_voice_files(engine, isolated_dirs): + (isolated_dirs.custom_voices / "MyMix.pt").write_bytes(b"not a real tensor") + + backend = registry.get_engine("kokoro", engine=engine) + voices = backend.get_voices() + + assert voices == [VoiceInfo(id="MyMix", display_name="MyMix", lang_code=None, is_custom=True)] + + +def test_mix_voices_delegates_to_wrapped_engine(engine, monkeypatch): + backend = registry.get_engine("kokoro", engine=engine) + + async def fake_mix_voices(v1, v2, ratio, new_name, op="mix"): + return (True, (v1, v2, ratio, new_name, op), None) + + monkeypatch.setattr(engine, "mix_voices", fake_mix_voices) + + ok, payload, _ = asyncio.run(backend.mix_voices("af_heart", "af_bella", 0.5, "blend", op="add")) + assert ok is True + assert payload == ("af_heart", "af_bella", 0.5, "blend", "add") + + +def test_cancel_delegates_to_wrapped_engine(engine): + backend = registry.get_engine("kokoro", engine=engine) + engine.cancel = MagicMock() + + backend.cancel() + + engine.cancel.assert_called_once_with() + + +def test_dummy_registered_and_shaped_like_a_real_backend(): + assert "dummy" in registry.list_engines() + assert DummyBackendAdapter.capabilities.supports_voice_mixing is False + + backend = registry.get_engine("dummy") + keys = {f.key for f in backend.get_config_schema()} + assert keys == { + "lang_code", "voice", "speed", "pitch", "split_pattern", + "format", "num_threads", "caching", + } + assert backend.get_voices() == [VoiceInfo(id="dummy", display_name="Dummy Tone", lang_code=None, is_custom=False)] + + +def test_dummy_engine_produces_real_nonsilent_audio(tmp_path): + """Sanity check that DummyEngine's fake pipeline actually writes audible + (non-silent) audio through the same process_chunk_task shape as + CachingMixin, exercising the generic FX/write path with no cache.""" + import numpy as np + import soundfile as sf + + engine = DummyEngine() + try: + config = { + "lang_code": "a", "voice": "dummy", "speed": 1.0, "split_pattern": r"\n+", + "filename": "out", "time_id": "1", "out_dir": str(tmp_path), "format": "wav", + "apply_fx": False, + } + files = engine.process_chunk_task((0, "Hello there.", config), None) + assert len(files) == 1 + data, sr = sf.read(files[0]["path"]) + assert sr == 24000 + assert np.max(np.abs(data)) > 0.01 + finally: + engine.worker.stop() + + +# Engine-picker switch behavior (switch to dummy, mixing-dock visibility, +# refuse-while-job-running, etc.) is covered by +# tests/gui_qt/test_qt_engine_backend.py now that the Tk frontend has been +# retired - see PLAN_qt_and_engine_abstraction.md. diff --git a/tests/test_engines_audio8.py b/tests/test_engines_audio8.py new file mode 100644 index 0000000..6941b97 --- /dev/null +++ b/tests/test_engines_audio8.py @@ -0,0 +1,369 @@ +"""Tests for the Audio8 TTS engine backend (kokoro_gui/engines/audio8_tts.py) - +a real, non-Kokoro second backend built on the same TTSEngineBackend contract +`tests/test_engine_backend.py` covers for Kokoro/Dummy. + +Never touches the real `transformers`/Audio8 model - `Audio8Engine.generate_segment` +(the one method that would load/call it) is monkeypatched in every test that +exercises generation, mirroring how `fake_pipeline` keeps Kokoro's tests off +the real `kokoro.KPipeline`/eSpeak NG. +""" +import os +import subprocess +import sys +import types +from pathlib import Path + +import numpy as np +import pytest +import soundfile as sf + +import kokoro_engine +from kokoro_gui.engines import audio8_tts, registry +from kokoro_gui.engines.audio8_tts import ( + Audio8BackendAdapter, Audio8Engine, Audio8ReferenceStore, +) +from kokoro_gui.engines.base import ConfigField, EngineCapabilities, VoiceInfo +from tests.conftest import strip_ansi + + +@pytest.fixture +def isolated_audio8_refs(tmp_path, monkeypatch): + refs_dir = tmp_path / "audio8_refs" + monkeypatch.setattr(audio8_tts, "AUDIO8_REFS_DIR", str(refs_dir)) + return refs_dir + + +@pytest.fixture +def audio8_engine(isolated_audio8_refs, isolated_dirs): + e = Audio8Engine() + yield e + e.worker.stop() + + +@pytest.fixture +def a_wav(tmp_path): + path = tmp_path / "sample.wav" + audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(1600) / 16000)).astype(np.float32) + sf.write(str(path), audio, 16000) + return str(path) + + +# --- registration / capabilities / schema ----------------------------------- + +def test_audio8_registered_and_shaped_like_a_real_backend(): + assert "audio8" in registry.list_engines() + caps = Audio8BackendAdapter.capabilities + assert isinstance(caps, EngineCapabilities) + assert caps.supports_voice_mixing is False + assert caps.supports_voice_cloning is True + assert caps.supports_multi_speaker_script is True + assert caps.supports_jit_streaming is False + + +def test_get_engine_wraps_the_given_engine_instance(audio8_engine): + backend = registry.get_engine("audio8", engine=audio8_engine) + assert isinstance(backend, Audio8BackendAdapter) + assert backend.engine is audio8_engine + + +def test_config_schema_shape(audio8_engine): + backend = registry.get_engine("audio8", engine=audio8_engine) + schema = backend.get_config_schema() + assert all(isinstance(f, ConfigField) for f in schema) + + keys = {f.key for f in schema} + assert keys == { + "lang_code", "voice", "speed", "split_pattern", "format", "num_threads", + "caching", "cache_reference_codes", + "max_new_tokens", "temperature", "top_p", "top_k", + } + + by_key = {f.key: f for f in schema} + # Fixed, engine-declared language list - NOT GUI-resolved like Kokoro's. + assert by_key["lang_code"].choices is not None + assert ("English", "English") in by_key["lang_code"].choices + # Voice IS GUI-resolved (from saved references), same convention as Kokoro. + assert by_key["voice"].choices is None + # Parallelism is capped low - see module docstring on the shared model lock. + assert by_key["num_threads"].max == 4 + # Model-specific sampling knobs get their own group, not Generation/Advanced. + assert by_key["max_new_tokens"].group == "Model" + assert by_key["temperature"].group == "Model" + assert by_key["top_p"].group == "Model" + assert by_key["top_k"].group == "Model" + + +def test_importing_module_does_not_load_the_model(): + """Importing this module (which happens at every app startup, to + register the backend) must not trigger `AutoModel.from_pretrained`/a + download - only `init_pipeline_async`/first generation does. + (`transformers` itself is already an indirect hard dependency via the + `kokoro` package, so the meaningful guarantee is "no model load", not + "no transformers import" - see this module's docstring.)""" + code = ( + "import kokoro_engine, kokoro_gui.engines.audio8_tts as a8; " + "print(a8._model is None and a8._processor is None)" + ) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, + cwd=str(Path(__file__).resolve().parent.parent)) + assert result.returncode == 0, result.stderr + assert strip_ansi(result.stdout).strip() == "True" + + +# --- Audio8ReferenceStore ---------------------------------------------------- + +def test_reference_store_round_trip(isolated_audio8_refs, a_wav): + assert Audio8ReferenceStore.list_references() == [] + + saved_path = Audio8ReferenceStore.save_reference("Alice", a_wav, "Hello, this is Alice speaking.") + assert os.path.exists(saved_path) + assert Audio8ReferenceStore.list_references() == ["Alice"] + assert Audio8ReferenceStore.get_transcript("Alice") == "Hello, this is Alice speaking." + + Audio8ReferenceStore.delete_reference("Alice") + assert Audio8ReferenceStore.list_references() == [] + + +def test_reference_store_sanitizes_path_traversal_name(isolated_audio8_refs, a_wav): + saved_path = Audio8ReferenceStore.save_reference("../../evil", a_wav, "transcript") + + # Must land inside AUDIO8_REFS_DIR under the basename, not escape it - + # same pattern as tests/test_resolve_voice_path.py's traversal guard. + assert os.path.exists(saved_path) + assert os.path.dirname(saved_path) == str(isolated_audio8_refs) + assert os.path.basename(saved_path) == "evil.wav" + + +def test_reference_store_ignores_incomplete_pairs(isolated_audio8_refs): + os.makedirs(str(isolated_audio8_refs), exist_ok=True) + (isolated_audio8_refs / "orphan.wav").write_bytes(b"not a real wav") + assert Audio8ReferenceStore.list_references() == [] + + +def test_get_voices_reflects_saved_references(isolated_audio8_refs, a_wav): + backend = Audio8BackendAdapter() + try: + assert backend.get_voices() == [] + Audio8ReferenceStore.save_reference("Bob", a_wav, "This is Bob.") + assert backend.get_voices() == [VoiceInfo(id="Bob", display_name="Bob", lang_code=None, is_custom=True)] + finally: + backend.cancel() + backend.engine.worker.stop() + + +# --- Audio8Engine: resolve_voice_path / resolve_voice_transcript ------------ + +def test_resolve_voice_path_and_transcript_for_saved_reference(audio8_engine, isolated_audio8_refs, a_wav): + Audio8ReferenceStore.save_reference("Carol", a_wav, "Carol's voice sample.") + + resolved = audio8_engine.resolve_voice_path("Carol") + assert os.path.isabs(resolved) + assert resolved.endswith("Carol.wav") + + assert audio8_engine.resolve_voice_transcript(resolved) == "Carol's voice sample." + + +def test_resolve_voice_path_falls_back_to_literal_file(audio8_engine, a_wav): + # Not a saved reference name, but an existing absolute wav path - usable + # ad-hoc even before being saved under a name. + assert audio8_engine.resolve_voice_path(a_wav) == a_wav + + +def test_resolve_voice_transcript_empty_when_no_sidecar(audio8_engine, a_wav): + assert audio8_engine.resolve_voice_transcript(a_wav) == "" + + +# --- Audio8Engine.process_chunk_task ----------------------------------------- + +def _fake_segment(monkeypatch, engine, freq=220.0, sr=44100, n=2200): + def _gen(text, ref_wav_path, ref_transcript, speed, lang_code): + t = np.arange(n) / sr + return (0.1 * np.sin(2 * np.pi * freq * t)).astype(np.float32) + monkeypatch.setattr(engine, "generate_segment", _gen) + + +def test_process_chunk_task_writes_44100hz_audio(audio8_engine, isolated_audio8_refs, isolated_dirs, a_wav, monkeypatch): + Audio8ReferenceStore.save_reference("Dana", a_wav, "Dana's reference line.") + _fake_segment(monkeypatch, audio8_engine) + + config = { + "lang_code": "English", "voice": audio8_engine.resolve_voice_path("Dana"), + "speed": 1.0, "split_pattern": r"\n+", "filename": "out", "time_id": "1", + "out_dir": str(isolated_dirs.out_dir), "format": "wav", "caching": False, + "apply_fx": False, + } + files = audio8_engine.process_chunk_task((0, "Hello there.", config), None) + + assert len(files) == 1 + data, sr = sf.read(files[0]["path"]) + assert sr == 44100 + assert np.max(np.abs(data)) > 0.01 + + +# process_chunk_task's own segment-cache-enabled integration test lives in +# tests/test_caching.py (the only module allowed to enable that setting - +# see tests/test_meta_caching_policy.py) as +# test_audio8_process_chunk_task_caching_keys_on_transcript. + + +# --- Audio8Engine: reference-codes cache (_reference_codes_path) ------------ +# +# `_get_model` is monkeypatched to a lightweight fake model/processor pair, +# same "never touch the real model" convention as `_fake_segment` above, but +# one level lower - these tests exercise `_reference_codes_path`/ +# `generate_segment`'s branching itself, not just its caller. + +def _make_fake_model_and_processor(monkeypatch): + import torch + + calls = {"processor": [], "encode_audio": 0, "generate_audio": []} + + def fake_processor(text, reference_audio=None, reference_text=None, + reference_codes=None, return_tensors="pt"): + calls["processor"].append({ + "text": text, "reference_audio": reference_audio, + "reference_text": reference_text, "reference_codes": reference_codes, + }) + # Mirrors the real `ArkttsProcessor._prompt_segments` constraint: + # `reference_text` is required whenever *either* reference kwarg is + # given - it's baked into the text prompt tokens, not just an audio- + # encode input. Enforcing it here is what caught the real bug where + # the reference_codes branch dropped reference_text entirely. + if (reference_audio is not None or reference_codes is not None) and not reference_text: + raise ValueError("reference_text is required when a reference voice is provided") + return { + "reference_audio_values": torch.zeros((1, 1, 4)), + "reference_audio_lengths": torch.tensor([4]), + } + + def fake_encode_audio(audio_values, audio_lengths): + calls["encode_audio"] += 1 + return torch.arange(30, dtype=torch.long).reshape(1, 10, 3), torch.tensor([3]) + + def fake_generate_audio(**kwargs): + calls["generate_audio"].append(kwargs) + return torch.zeros((1, 100)), torch.tensor([100]), None + + fake_model = types.SimpleNamespace(encode_audio=fake_encode_audio, generate_audio=fake_generate_audio) + monkeypatch.setattr(audio8_tts, "_get_model", lambda: (fake_model, fake_processor)) + return calls + + +def test_reference_codes_path_none_without_a_fingerprintable_file(audio8_engine): + assert audio8_engine._reference_codes_path("", "some transcript") is None + assert audio8_engine._reference_codes_path("not-a-real-path.wav", "some transcript") is None + + +def test_reference_codes_path_none_without_a_transcript(audio8_engine, a_wav): + # A fresh (uncached) reference can't be encoded without reference_text - + # `ArkttsProcessor._prompt_segments` requires it whenever reference audio + # is given (see generate_segment's docstring). + assert audio8_engine._reference_codes_path(a_wav, "") is None + + +def test_reference_codes_path_computes_and_persists_on_first_call(audio8_engine, isolated_audio8_refs, a_wav, monkeypatch): + calls = _make_fake_model_and_processor(monkeypatch) + + path = audio8_engine._reference_codes_path(a_wav, "A reference transcript.") + + assert path is not None + assert os.path.commonpath([path, str(isolated_audio8_refs)]) == str(isolated_audio8_refs) + assert calls["encode_audio"] == 1 + loaded = np.load(path) + assert loaded.shape == (10, 3) + assert loaded.dtype == np.int64 + + +def test_reference_codes_path_reuses_cache_without_recomputing(audio8_engine, isolated_audio8_refs, a_wav, monkeypatch): + calls = _make_fake_model_and_processor(monkeypatch) + first = audio8_engine._reference_codes_path(a_wav, "A reference transcript.") + assert calls["encode_audio"] == 1 + + second = audio8_engine._reference_codes_path(a_wav, "A reference transcript.") + assert second == first + assert calls["encode_audio"] == 1 # not called again - served from disk + + +def test_generate_segment_passes_reference_codes_when_cache_enabled(audio8_engine, isolated_audio8_refs, a_wav, monkeypatch): + calls = _make_fake_model_and_processor(monkeypatch) + audio8_engine.cache_reference_codes = True + + audio8_engine.generate_segment("Hello.", a_wav, "A reference transcript.", 1.0, "English") + + # First call: the one-off probe encode. Second call: the real generation + # call, which must use reference_codes now that the cache is populated. + assert len(calls["processor"]) == 2 + gen_call = calls["processor"][-1] + assert gen_call["reference_codes"] is not None + assert gen_call["reference_audio"] is None + assert gen_call["reference_text"] == "A reference transcript." + + +def test_generate_segment_uses_raw_reference_audio_when_cache_disabled(audio8_engine, isolated_audio8_refs, a_wav, monkeypatch): + calls = _make_fake_model_and_processor(monkeypatch) + audio8_engine.cache_reference_codes = False + + audio8_engine.generate_segment("Hello.", a_wav, "A reference transcript.", 1.0, "English") + + assert len(calls["processor"]) == 1 # no probe encode - never even fingerprinted + assert calls["encode_audio"] == 0 + gen_call = calls["processor"][-1] + assert gen_call["reference_audio"] == a_wav + assert gen_call["reference_codes"] is None + + +def test_generate_segment_forwards_sampling_knob_defaults(audio8_engine, isolated_audio8_refs, a_wav, monkeypatch): + calls = _make_fake_model_and_processor(monkeypatch) + + audio8_engine.generate_segment("Hello.", a_wav, "A reference transcript.", 1.0, "English") + + assert len(calls["generate_audio"]) == 1 + kwargs = calls["generate_audio"][0] + assert kwargs["max_new_tokens"] == 1024 + assert kwargs["temperature"] == 0.8 + assert kwargs["top_p"] == 0.95 + assert kwargs["top_k"] == 50 + + +def test_process_chunk_task_reads_sampling_knobs_from_config(audio8_engine, isolated_audio8_refs, isolated_dirs, a_wav, monkeypatch): + Audio8ReferenceStore.save_reference("Dana", a_wav, "Dana's reference line.") + calls = _make_fake_model_and_processor(monkeypatch) + + config = { + "lang_code": "English", "voice": audio8_engine.resolve_voice_path("Dana"), + "speed": 1.0, "split_pattern": r"\n+", "filename": "out", "time_id": "1", + "out_dir": str(isolated_dirs.out_dir), "format": "wav", "caching": False, + "apply_fx": False, "max_new_tokens": 256, "temperature": 1.1, "top_p": 0.5, "top_k": 10, + } + audio8_engine.process_chunk_task((0, "Hello there.", config), None) + + assert audio8_engine.max_new_tokens == 256 + assert audio8_engine.temperature == 1.1 + assert audio8_engine.top_p == 0.5 + assert audio8_engine.top_k == 10 + kwargs = calls["generate_audio"][0] + assert kwargs["max_new_tokens"] == 256 + assert kwargs["temperature"] == 1.1 + assert kwargs["top_p"] == 0.5 + assert kwargs["top_k"] == 10 + + +def test_process_chunk_task_reads_cache_reference_codes_from_config(audio8_engine, isolated_audio8_refs, isolated_dirs, a_wav, monkeypatch): + Audio8ReferenceStore.save_reference("Dana", a_wav, "Dana's reference line.") + _fake_segment(monkeypatch, audio8_engine) + assert audio8_engine.cache_reference_codes is True # __init__ default + + config = { + "lang_code": "English", "voice": audio8_engine.resolve_voice_path("Dana"), + "speed": 1.0, "split_pattern": r"\n+", "filename": "out", "time_id": "1", + "out_dir": str(isolated_dirs.out_dir), "format": "wav", "caching": False, + "apply_fx": False, "cache_reference_codes": False, + } + audio8_engine.process_chunk_task((0, "Hello there.", config), None) + assert audio8_engine.cache_reference_codes is False + + +def test_cancel_sets_cancel_event(audio8_engine): + assert not audio8_engine.cancel_event.is_set() + audio8_engine.cancel() + assert audio8_engine.cancel_event.is_set() diff --git a/tests/test_generate_clip_audio.py b/tests/test_generate_clip_audio.py new file mode 100644 index 0000000..7d1b61c --- /dev/null +++ b/tests/test_generate_clip_audio.py @@ -0,0 +1,66 @@ +"""Tests for KokoroEngine.generate_clip_audio (kokoro_gui/engine/conversion.py) - +the per-clip Generate entry point for the DAW redesign's timeline dock. +Mirrors tests/test_generate_preview.py's conventions.""" +import asyncio +import os + + +def test_generate_clip_audio_writes_output_and_returns_segment_dicts(engine, fake_pipeline, make_config): + config = make_config() + results = asyncio.run(engine.generate_clip_audio((0, "Hello world.", config))) + + assert len(results) == 1 + assert os.path.exists(results[0]["path"]) + assert results[0]["text"] == "Hello world." + assert results[0]["duration"] > 0 + + +def test_generate_clip_audio_resolves_voice_path(engine, fake_pipeline, make_config, monkeypatch): + calls = [] + original_resolve = engine.resolve_voice_path + + def spy(voice): + calls.append(voice) + return original_resolve(voice) + + monkeypatch.setattr(engine, "resolve_voice_path", spy) + + config = make_config(voice="af_heart") + asyncio.run(engine.generate_clip_audio((0, "Hello.", config))) + + assert calls == ["af_heart"] + + +def test_generate_clip_audio_creates_missing_out_dir(engine, fake_pipeline, make_config, tmp_path): + missing_dir = tmp_path / "brand_new_out_dir" + assert not missing_dir.exists() + + config = make_config(out_dir=str(missing_dir)) + results = asyncio.run(engine.generate_clip_audio((0, "Hello.", config))) + + assert missing_dir.exists() + assert os.path.exists(results[0]["path"]) + + +def test_generate_clip_audio_clears_stale_cancel_event(engine, fake_pipeline, make_config): + engine.cancel_event.set() # simulate a previously cancelled run left set + + config = make_config() + results = asyncio.run(engine.generate_clip_audio((0, "Hello.", config))) + + assert len(results) == 1 # not silently [] because cancel_event was still set + + +def test_generate_clip_audio_does_not_mutate_caller_config(engine, fake_pipeline, make_config): + config = make_config(voice="af_heart") + original = dict(config) + + asyncio.run(engine.generate_clip_audio((0, "Hello.", config))) + + assert config == original # generate_clip_audio copies the dict before resolving voice + + +# Note: a caching-enabled cache-hit test for generate_clip_audio lives in +# tests/test_caching.py instead of here - tests/test_meta_caching_policy.py +# enforces that the caching config flag is only ever turned on in that one +# module. diff --git a/tests/test_generate_dirty_clips.py b/tests/test_generate_dirty_clips.py new file mode 100644 index 0000000..9a6eb41 --- /dev/null +++ b/tests/test_generate_dirty_clips.py @@ -0,0 +1,123 @@ +"""Tests for KokoroEngine.generate_dirty_clips (kokoro_gui/engine/conversion.py) - +the batch dirty-scoped Generate entry point for item 3 ("Consolidated action +bar + batch dirty-scoped generation") of the DAW-for-text remaining-work +roadmap. Mirrors tests/test_generate_clip_audio.py's fixtures/conventions.""" +import asyncio +import os + +import pytest + + +def test_all_clips_succeed_returns_one_outcome_per_clip_with_distinct_filenames(engine, fake_pipeline, make_config): + clips_with_configs = [ + ("clip-1", "Hello there.", make_config(filename="run", time_id="1")), + ("clip-2", "General Kenobi.", make_config(filename="run", time_id="1")), + ("clip-3", "You are a bold one.", make_config(filename="run", time_id="1")), + ] + + outcomes = asyncio.run(engine.generate_dirty_clips(clips_with_configs)) + + assert len(outcomes) == 3 + assert [o["clip_id"] for o in outcomes] == ["clip-1", "clip-2", "clip-3"] + assert all(o["success"] for o in outcomes) + assert all(not o["cancelled"] for o in outcomes) + + # Every clip's config shares the same filename/time_id - the per-clip + # batch-local index (assigned via enumerate()) is what keeps their + # output files from colliding on disk. + paths = [o["results"][0]["path"] for o in outcomes] + assert len(set(paths)) == 3 + for path in paths: + assert os.path.exists(path) + + +def test_one_clip_raises_others_still_complete(engine, fake_pipeline, make_config, monkeypatch): + clips_with_configs = [ + ("clip-1", "First paragraph.", make_config(filename="run", time_id="1")), + ("clip-2", "Second paragraph.", make_config(filename="run", time_id="1")), + ("clip-3", "Third paragraph.", make_config(filename="run", time_id="1")), + ] + + real_task = engine.process_chunk_task + call_count = {"n": 0} + + def flaky(chunk_data, progress_callback): + call_count["n"] += 1 + if call_count["n"] == 2: + raise RuntimeError("boom") + return real_task(chunk_data, progress_callback) + + monkeypatch.setattr(engine, "process_chunk_task", flaky) + + outcomes = asyncio.run(engine.generate_dirty_clips(clips_with_configs)) + + assert len(outcomes) == 3 + successes = [o for o in outcomes if o["success"]] + failures = [o for o in outcomes if not o["success"]] + assert len(successes) == 2 + assert len(failures) == 1 + assert "boom" in failures[0]["error"] + assert not failures[0]["cancelled"] + + +def test_empty_input_list_returns_empty_list_with_no_side_effects(engine, fake_pipeline, make_config, isolated_dirs): + outcomes = asyncio.run(engine.generate_dirty_clips([])) + + assert outcomes == [] + assert list(isolated_dirs.out_dir.iterdir()) == [] + + +def test_cancel_mid_batch_skips_queued_clips_without_calling_generate_clip_audio( + engine, fake_pipeline, make_config, monkeypatch +): + # num_threads=1 makes ordering deterministic: clip-1 dispatches first, + # sets cancel_event as a side effect, and clip-2/clip-3 must never reach + # generate_clip_audio at all. + clips_with_configs = [ + ("clip-1", "First.", make_config(filename="run", time_id="1", num_threads=1)), + ("clip-2", "Second.", make_config(filename="run", time_id="1", num_threads=1)), + ("clip-3", "Third.", make_config(filename="run", time_id="1", num_threads=1)), + ] + + calls = [] + real_generate_clip_audio = engine.generate_clip_audio + + async def spy(chunk_data, progress_callback=None): + calls.append(chunk_data[1]) # record the text this call was for + result = await real_generate_clip_audio(chunk_data, progress_callback) + # Simulate the user hitting Cancel right after the first clip + # dispatches, while clip-2/clip-3 are still queued behind the + # num_threads=1 semaphore. + engine.cancel_event.set() + return result + + monkeypatch.setattr(engine, "generate_clip_audio", spy) + + outcomes = asyncio.run(engine.generate_dirty_clips(clips_with_configs)) + + assert len(outcomes) == 3 + by_id = {o["clip_id"]: o for o in outcomes} + + # clip-1 got to run before cancellation. + assert by_id["clip-1"]["success"] is True + assert by_id["clip-1"]["cancelled"] is False + + # clip-2/clip-3 were still queued when cancel_event got set - they must + # be skipped with a distinct "cancelled" outcome, not a generic failure, + # and generate_clip_audio must never have been called for them at all + # (this is the actual bug being guarded against: a queued clip's + # eventual turn calling generate_clip_audio would silently re-clear + # cancel_event via its own unconditional `self.cancel_event.clear()`). + for clip_id in ("clip-2", "clip-3"): + outcome = by_id[clip_id] + assert outcome["success"] is False + assert outcome["cancelled"] is True + + assert calls == ["First."] # generate_clip_audio was only ever called once + + +@pytest.mark.skip(reason="Timing-based concurrency assertions are inherently flaky under CI load; " + "the Semaphore(num_threads) bound is already exercised structurally by " + "the cancel-race test above (num_threads=1 forces deterministic ordering).") +def test_concurrency_bound_lets_multiple_clips_run_in_parallel(): + pass diff --git a/tests/test_generation_stats.py b/tests/test_generation_stats.py new file mode 100644 index 0000000..335b39f --- /dev/null +++ b/tests/test_generation_stats.py @@ -0,0 +1,68 @@ +"""kokoro_gui/engine/stats.py - the per-engine generation-history store that +seeds/refines the batch conversion ETA (kokoro_gui/engine/conversion.py). + +Uses the `isolated_dirs` fixture (tests/conftest.py) purely for its +monkeypatched `kokoro_engine.STATS_FILE`, so these tests never touch a real +generation_stats.json in the repo working directory. +""" +import json + +import kokoro_engine +from kokoro_gui.engine import stats as generation_stats + + +def test_estimate_chars_per_sec_with_no_history_returns_none(isolated_dirs): + assert generation_stats.estimate_chars_per_sec("kokoro") is None + + +def test_record_and_estimate_round_trip(isolated_dirs): + generation_stats.record_generation("kokoro", chars=1000, words=180, duration=10.0) + assert generation_stats.estimate_chars_per_sec("kokoro") == 100.0 + + +def test_estimate_sums_across_history_rather_than_averaging_per_run_rates(isolated_dirs): + # One long, slow run and one short, fast run: summing chars/summing + # duration should weight the long run more heavily than a naive average + # of each run's own rate would. + generation_stats.record_generation("kokoro", chars=9000, words=1500, duration=90.0) # 100 chars/s + generation_stats.record_generation("kokoro", chars=100, words=20, duration=0.5) # 200 chars/s + rate = generation_stats.estimate_chars_per_sec("kokoro") + total_chars, total_duration = 9100, 90.5 + assert rate == total_chars / total_duration + assert rate < 150.0 # nowhere near a plain average of the two per-run rates + + +def test_stats_are_isolated_per_engine(isolated_dirs): + generation_stats.record_generation("kokoro", chars=1000, words=200, duration=10.0) + assert generation_stats.estimate_chars_per_sec("dummy") is None + assert generation_stats.estimate_chars_per_sec("audio8") is None + + generation_stats.record_generation("audio8", chars=100, words=20, duration=50.0) + assert generation_stats.estimate_chars_per_sec("kokoro") == 100.0 + assert generation_stats.estimate_chars_per_sec("audio8") == 2.0 + + +def test_record_generation_ignores_zero_chars(isolated_dirs): + generation_stats.record_generation("kokoro", chars=0, words=0, duration=5.0) + assert generation_stats.estimate_chars_per_sec("kokoro") is None + + +def test_record_generation_ignores_non_positive_duration(isolated_dirs): + generation_stats.record_generation("kokoro", chars=500, words=90, duration=0.0) + generation_stats.record_generation("kokoro", chars=500, words=90, duration=-1.0) + assert generation_stats.estimate_chars_per_sec("kokoro") is None + + +def test_record_generation_trims_to_history_limit(isolated_dirs): + for i in range(generation_stats.HISTORY_LIMIT + 5): + generation_stats.record_generation("kokoro", chars=100, words=20, duration=1.0) + + with open(kokoro_engine.STATS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + assert len(data["kokoro"]) == generation_stats.HISTORY_LIMIT + + +def test_record_generation_missing_engine_id_falls_back_to_unknown_bucket(isolated_dirs): + generation_stats.record_generation(None, chars=100, words=20, duration=2.0) + assert generation_stats.estimate_chars_per_sec(None) == 50.0 + assert generation_stats.estimate_chars_per_sec("unknown") == 50.0 diff --git a/tests/test_gui_config_assembly.py b/tests/test_gui_config_assembly.py deleted file mode 100644 index 5d464b9..0000000 --- a/tests/test_gui_config_assembly.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Tests for the config-dict assembly contract in start_conversion/ -preview_conversion (gui.py:1528-1747).""" -import os -import re -import tempfile - - -def _set_text(app, text): - app.text_entry.delete("1.0", "end") - app.text_entry.insert("1.0", text) - - -BASE_KEYS = { - "lang_code", "voice", "speed", "split_pattern", "filename", "format", - "out_dir", "separate", "combine", "export_subtitles", "caching", - "time_id", "num_threads", "volume", "pitch", "normalize", - "trim_silence", "lexicon", -} - -FX_KEYS = { - "reverb_enabled", "reverb_room_size", "reverb_wet_level", "reverb_damping", - "reverb_dry_level", "reverb_width", "eq_bass", "eq_treble", - "comp_enabled", "comp_threshold", "comp_ratio", "comp_attack", "comp_release", - "distortion_enabled", "distortion_drive", - "chorus_enabled", "chorus_rate", "chorus_depth", "chorus_mix", - "phaser_enabled", "phaser_rate", "phaser_depth", "phaser_mix", - "clipping_enabled", "clipping_thresh", - "bitcrush_enabled", "bitcrush_depth", "gsm_enabled", - "highpass_enabled", "highpass_freq", "lowpass_enabled", "lowpass_freq", - "delay_enabled", "delay_time", "delay_feedback", "delay_mix", - "pitch_shift_enabled", "pitch_shift_semitones", - "limiter_enabled", "limiter_threshold", "limiter_release", - "gain_enabled", "gain_db", -} - - -def test_start_conversion_assembles_full_key_set(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.apply_fx_var.set(True) - tts_app.start_conversion() - - assert tts_app.engine.start_conversion.called - text_arg, config = tts_app.engine.start_conversion.call_args[0] - assert text_arg == "Hello world." - assert BASE_KEYS <= config.keys() - assert FX_KEYS <= config.keys() - assert re.fullmatch(r"\d{14}", config["time_id"]) - - -def test_start_conversion_apply_fx_false_omits_fx_keys(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.apply_fx_var.set(False) - tts_app.start_conversion() - - _, config = tts_app.engine.start_conversion.call_args[0] - assert "reverb_enabled" not in config - assert "gain_db" not in config - - -def test_start_conversion_jit_enabled_routes_to_start_jit_conversion(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.jit_enabled.set(True) - tts_app.start_conversion() - - assert tts_app.engine.start_jit_conversion.called - assert not tts_app.engine.start_conversion.called - - -def test_start_conversion_blocks_when_pipeline_not_ready(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.engine.pipeline = None - tts_app.start_conversion() - - assert not tts_app.engine.start_conversion.called - assert not tts_app.engine.start_jit_conversion.called - - -def test_start_conversion_empty_text_shows_warning(tts_app): - _set_text(tts_app, "") - tts_app.start_conversion() - - assert not tts_app.engine.start_conversion.called - assert not tts_app.engine.start_jit_conversion.called - - -def test_preview_conversion_assembles_smaller_extra_config(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.apply_fx_var.set(False) - tts_app.preview_conversion() - - assert tts_app.engine.generate_preview.called - args, kwargs = tts_app.engine.generate_preview.call_args - preview_text, voice, speed, out_path, extra_config = args[:5] - assert set(extra_config.keys()) == {"volume", "pitch", "normalize", "trim_silence", "lexicon"} - assert voice == tts_app.voice_var.get() - assert speed == tts_app.speed_var.get() - - -def test_preview_conversion_apply_fx_true_adds_fx_keys(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.apply_fx_var.set(True) - tts_app.preview_conversion() - - args, kwargs = tts_app.engine.generate_preview.call_args - extra_config = args[4] - assert FX_KEYS <= extra_config.keys() - assert "voice" not in extra_config - assert "out_dir" not in extra_config - - -def test_preview_conversion_uses_tempdir_wav_path(tts_app): - _set_text(tts_app, "Hello world.") - tts_app.preview_conversion() - - args, kwargs = tts_app.engine.generate_preview.call_args - out_path = args[3] - assert out_path == os.path.join(tempfile.gettempdir(), "kokoro_preview.wav") diff --git a/tests/test_gui_handlers.py b/tests/test_gui_handlers.py deleted file mode 100644 index 50bbca3..0000000 --- a/tests/test_gui_handlers.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for assorted GUI event handlers: lexicon add/delete, thread-count -clamp, mix-name validation, preset save-dialog sanitization, load_fx_preset -safety, and a documented existing bug in refresh_voice_lists.""" -import json -import os - -import pytest - - -def test_add_lexicon_rule_persists_and_refreshes(tts_app): - import gui - tts_app.lex_orig_var.set("hello") - tts_app.lex_replace_var.set("hi") - tts_app.add_lexicon_rule() - - assert tts_app.settings["lexicon"]["hello"] == "hi" - with open(gui.CONFIG_FILE, "r", encoding="utf-8") as f: - saved = json.load(f) - assert saved["lexicon"]["hello"] == "hi" - - -def test_add_lexicon_rule_empty_original_shows_warning(tts_app): - import gui - tts_app.lex_orig_var.set("") - tts_app.lex_replace_var.set("hi") - tts_app.add_lexicon_rule() - - assert tts_app.settings.get("lexicon", {}) == {} - assert gui.messagebox.showwarning.called - - -def test_delete_lexicon_rule_removes_key(tts_app): - tts_app.settings["lexicon"] = {"hello": "hi"} - tts_app.delete_lexicon_rule("hello") - - assert "hello" not in tts_app.settings["lexicon"] - - -@pytest.mark.parametrize("start,delta,expected", [ - (1, -5, 1), - (16, 5, 16), - (5, 2, 7), -]) -def test_change_threads_clamps_1_to_16(tts_app, start, delta, expected): - tts_app.num_threads_var.set(start) - tts_app.change_threads(delta) - assert tts_app.num_threads_var.get() == expected - - -def test_mix_voice_action_rejects_invalid_name_chars(tts_app): - tts_app.mix_name_var.set("bad name!") - tts_app.mix_voice_action() - - assert not tts_app.engine.mix_voices.called - - -def test_mix_voice_action_prompts_overwrite_confirmation(tts_app): - import gui - existing = tts_app.get_all_voices()[0] - tts_app.mix_name_var.set(existing) - gui.messagebox.askyesno.return_value = False - - tts_app.mix_voice_action() - - assert not tts_app.engine.mix_voices.called - - -def test_save_preset_dialog_sanitizes_name(tts_app, monkeypatch): - import gui - - class FakeDialog: - def __init__(self, *a, **kw): - pass - - def get_input(self): - return 'Bad/Na:me' - - monkeypatch.setattr(gui.ctk, "CTkInputDialog", FakeDialog) - tts_app.save_preset_dialog() - - assert os.path.exists(os.path.join(gui.PRESETS_DIR, "BadName.json")) - - -def test_save_fx_preset_dialog_sanitizes_name(tts_app, monkeypatch): - import gui - - class FakeDialog: - def __init__(self, *a, **kw): - pass - - def get_input(self): - return 'Weird?Nam*e' - - monkeypatch.setattr(gui.ctk, "CTkInputDialog", FakeDialog) - tts_app.save_fx_preset_dialog() - - assert os.path.exists(os.path.join(gui.FX_PRESETS_DIR, "WeirdName.json")) - - -def test_load_fx_preset_basename_sanitized(tts_app): - import gui - os.makedirs(gui.FX_PRESETS_DIR, exist_ok=True) - with open(os.path.join(gui.FX_PRESETS_DIR, "real.json"), "w", encoding="utf-8") as f: - json.dump({"gain_db": 3.0}, f) - - tts_app.load_fx_preset("../../real") - - assert tts_app.gain_db.get() == 3.0 - - -def test_refresh_voice_lists_crashes_if_custom_voices_dir_missing(tts_app): - # Documents an existing asymmetry at gui.py:693 (os.listdir with no - # os.path.exists guard), unlike get_all_voices (gui.py:192) which does - # guard. Pins current behavior - do not silently "fix" by changing this - # assertion; if the guard is added, update this test deliberately. - os.rmdir("custom_voices") - with pytest.raises(FileNotFoundError): - tts_app.refresh_voice_lists() diff --git a/tests/test_gui_settings.py b/tests/test_gui_settings.py deleted file mode 100644 index fb6d20a..0000000 --- a/tests/test_gui_settings.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Tests for TTSApp.load_settings/save_settings/apply_settings -(gui.py:263-436).""" -import json - - -def test_load_settings_defaults_when_no_config_file(tts_app): - settings = tts_app.load_settings() - assert settings["voice"] == "af_heart" - assert settings["lexicon"] == {} - assert settings["caching"] is True - - -def test_load_settings_merges_existing_config_json(tts_app): - import gui - with open(gui.CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump({"voice": "am_adam"}, f) - - settings = tts_app.load_settings() - - assert settings["voice"] == "am_adam" - assert settings["format"] == "wav" # untouched default still present - - -def test_load_settings_corrupt_json_falls_back_to_defaults(tts_app): - import gui - with open(gui.CONFIG_FILE, "w", encoding="utf-8") as f: - f.write("{not valid json") - - settings = tts_app.load_settings() - - assert settings["voice"] == "af_heart" - - -def test_save_settings_writes_json_with_current_vars(tts_app): - import gui - tts_app.voice_var.set("am_liam") - tts_app.save_settings() - - with open(gui.CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - assert data["voice"] == "am_liam" - - -def test_change_appearance_and_scaling_persist_to_settings(tts_app): - tts_app.change_appearance("Light") - tts_app.change_scaling("120%") - - assert tts_app.settings["appearance"] == "Light" - assert tts_app.settings["scaling"] == "120%" diff --git a/tests/test_mixer_transport.py b/tests/test_mixer_transport.py new file mode 100644 index 0000000..7329979 --- /dev/null +++ b/tests/test_mixer_transport.py @@ -0,0 +1,207 @@ +"""Tests for kokoro_gui/audio/mixer.py and kokoro_gui/audio/transport.py: +the block-mixing arithmetic and the transport state machine, driven with a +fake output stream so no audio device is ever opened (same rule as +conftest.py's `playback` mock).""" +import numpy as np +import pytest +import soundfile as sf + +from kokoro_gui.audio import mixer +from kokoro_gui.audio.transport import ScheduledClip, Transport, render_block_for_test + +pytest.importorskip("PySide6") + + +def _write(path, samples, rate=8000): + sf.write(str(path), samples.astype(np.float32), rate) + return str(path) + + +class FakeStream: + instances = [] + + def __init__(self, sample_rate, callback): + self.sample_rate = sample_rate + self.callback = callback + self.started = False + self.closed = False + FakeStream.instances.append(self) + + def start(self): + self.started = True + + def stop(self): + self.started = False + + def close(self): + self.closed = True + + def pull(self, frames): + out = np.zeros((frames, 1), dtype=np.float32) + self.callback(out, frames) + return out[:, 0] + + +@pytest.fixture +def fake_factory(): + FakeStream.instances = [] + yield lambda rate, cb: FakeStream(rate, cb) + FakeStream.instances = [] + + +@pytest.fixture +def make_transport(fake_factory): + """Every `Transport` a test builds is stopped at teardown. A test that + leaves one playing (the loop test does) leaves its 30Hz `QTimer` + running; a later test's `processEvents` then ticks it while the + object is being garbage-collected, which crashed the Windows CI leg + with an access violation.""" + made = [] + + def _make(): + transport = Transport(stream_factory=fake_factory) + made.append(transport) + return transport + + yield _make + for transport in made: + transport.stop() + + +# -- mixer ----------------------------------------------------------------------- + + +def test_mix_block_sums_overlapping_clips_and_clips_to_unit_range(): + a = mixer.LoadedClip("a", start_frame=0, samples=np.full(10, 0.6, dtype=np.float32)) + b = mixer.LoadedClip("b", start_frame=5, samples=np.full(10, 0.6, dtype=np.float32)) + + out = mixer.mix_block([a, b], 0, 20) + + assert np.allclose(out[:5], 0.6) + assert np.allclose(out[5:10], 1.0) # 1.2 clipped + assert np.allclose(out[10:15], 0.6) + assert np.allclose(out[15:], 0.0) + + +def test_mix_block_applies_gain_and_partial_overlap(): + a = mixer.LoadedClip("a", start_frame=3, samples=np.ones(4, dtype=np.float32), gain=0.5) + + out = mixer.mix_block([a], 5, 4) # block covers frames 5..9, clip covers 3..7 + + assert np.allclose(out, [0.5, 0.5, 0.0, 0.0]) + + +def test_load_clip_samples_resamples_and_downmixes(tmp_path): + stereo = np.stack([np.ones(800), np.zeros(800)], axis=1) + path = _write(tmp_path / "s.wav", stereo, rate=8000) + + mono = mixer.load_clip_samples(path, 16000) + + assert mono.ndim == 1 + assert abs(len(mono) - 1600) <= 2 + assert np.allclose(mono[100:1500], 0.5, atol=0.05) + + +def test_total_frames_is_the_latest_end(): + a = mixer.LoadedClip("a", 0, np.zeros(10, dtype=np.float32)) + b = mixer.LoadedClip("b", 5, np.zeros(10, dtype=np.float32)) + assert mixer.total_frames([a, b]) == 15 + assert mixer.total_frames([]) == 0 + + +# -- transport -------------------------------------------------------------------- + + +def test_transport_load_positions_clips_and_reports_duration(tmp_path, make_transport): + path = _write(tmp_path / "a.wav", np.ones(8000), rate=8000) # 1s + transport = make_transport() + + transport.load([ScheduledClip("c1", 2.0, path)], sample_rate=8000, total_duration_s=5.0) + + clips = transport.loaded_clips() + assert clips[0].start_frame == 16000 + assert transport.duration() == 5.0 + assert transport.state == "stopped" + + +def test_transport_callback_mixes_at_the_right_frames(tmp_path, make_transport): + path = _write(tmp_path / "a.wav", np.full(8000, 0.5), rate=8000) + transport = make_transport() + transport.load([ScheduledClip("c1", 1.0, path)], sample_rate=8000) + + silence = render_block_for_test(transport, 4000) # frames 0..4000: before the clip + assert np.allclose(silence, 0.0) + transport.seek(1.0) + block = render_block_for_test(transport, 4000) # frames 8000..12000: inside + assert np.allclose(block, 0.5) + assert abs(transport.position() - 1.5) < 1e-6 + + +def test_transport_play_pause_stop_state_machine(tmp_path, make_transport): + path = _write(tmp_path / "a.wav", np.ones(8000), rate=8000) + transport = make_transport() + transport.load([ScheduledClip("c1", 0.0, path)], sample_rate=8000) + states = [] + transport.stateChanged.connect(states.append) + + transport.play() + assert transport.is_playing + assert FakeStream.instances[-1].started + transport.pause() + assert transport.state == "paused" + assert FakeStream.instances[-1].closed + transport.play() + transport.stop() + assert transport.state == "stopped" + assert transport.position() == 0.0 + assert states == ["playing", "paused", "playing", "stopped"] + + +def test_transport_reaching_the_end_stops_and_emits_finished(tmp_path, make_transport): + path = _write(tmp_path / "a.wav", np.ones(800), rate=8000) # 0.1s + transport = make_transport() + transport.load([ScheduledClip("c1", 0.0, path)], sample_rate=8000) + finished = [] + transport.finished.connect(lambda: finished.append(True)) + + transport.play() + FakeStream.instances[-1].pull(1000) # past the end + transport.process_pending() + + assert finished == [True] + assert transport.state == "stopped" + assert transport.position() == 0.1 + + +def test_transport_loop_wraps_instead_of_stopping(tmp_path, make_transport): + path = _write(tmp_path / "a.wav", np.ones(800), rate=8000) + transport = make_transport() + transport.load([ScheduledClip("c1", 0.0, path)], sample_rate=8000) + transport.loop = True + + transport.play() + FakeStream.instances[-1].pull(1000) + transport.process_pending() + + assert transport.is_playing + assert transport.position() == 200 / 8000 + + +def test_transport_play_with_nothing_loaded_is_a_noop(make_transport): + transport = make_transport() + transport.play() + assert transport.state == "stopped" + assert FakeStream.instances == [] + + +def test_transport_reload_keeps_position_and_skips_unreadable_paths(tmp_path, make_transport): + path = _write(tmp_path / "a.wav", np.ones(8000), rate=8000) + transport = make_transport() + transport.load([ScheduledClip("c1", 0.0, path)], sample_rate=8000) + transport.seek(0.5) + + transport.reload([ScheduledClip("c1", 0.0, path), ScheduledClip("c2", 1.0, str(tmp_path / "missing.wav")), + ScheduledClip("c3", 2.0, None)], sample_rate=8000) + + assert [c.clip_id for c in transport.loaded_clips()] == ["c1"] + assert abs(transport.position() - 0.5) < 1e-6 diff --git a/tests/test_pipeline_init.py b/tests/test_pipeline_init.py new file mode 100644 index 0000000..fc6acfb --- /dev/null +++ b/tests/test_pipeline_init.py @@ -0,0 +1,74 @@ +"""Tests for kokoro_engine.KokoroEngine.init_pipeline_async's fallback when +a foreign-format lang_code reaches KPipeline directly. + +Motivating bug report: a user who'd previously used the Audio8 backend (whose +lang_code values are full language names, e.g. "English" - see +kokoro_gui/engines/audio8_tts.py) saw "Pipeline Init Failed" on every launch +with the Kokoro engine active, even though the Settings dock's own combo +(kokoro_gui/qt/docks/settings_dock.py's SchemaFormWidget._set_combo) +reconciles an unrecognized stored value to a valid default when building the +Language field - confirmed independently, against the user's real +config_qt.json, to correctly resolve "English" to "a" before ever calling +init_pipeline_async. Since the exact mechanism by which a foreign value +still reached the real kokoro.KPipeline (whose own internal validation +raised the reported error) couldn't be pinned down further without a live +repro, this fallback hardens init_pipeline_async itself as a second, +independent line of defense - whatever reaches it, a value KPipeline +rejects gets one retry against Kokoro's own safe default instead of +surfacing a raw third-party AssertionError. +""" +import asyncio + +import kokoro_engine + + +def test_falls_back_to_safe_default_when_lang_code_is_invalid(engine, monkeypatch): + calls = [] + + def fake_kpipeline(lang_code="a"): + calls.append(lang_code) + if lang_code != "a": + raise AssertionError((lang_code, {"a": "American English"})) + return object() + + monkeypatch.setattr(kokoro_engine, "KPipeline", fake_kpipeline) + + result = asyncio.run(engine.init_pipeline_async("English")) + + assert result is True + assert calls == ["English", "a"] # tried the given value, then fell back once + assert engine.pipeline is not None + + +def test_does_not_retry_when_the_safe_default_itself_fails(engine, monkeypatch): + """"a" failing is a real problem (missing model, no network, etc.) - not + the foreign-lang_code case this fallback exists for - so it must not + mask that failure behind a pointless second attempt with the same + value.""" + calls = [] + + def fake_kpipeline(lang_code="a"): + calls.append(lang_code) + raise RuntimeError("no network") + + monkeypatch.setattr(kokoro_engine, "KPipeline", fake_kpipeline) + + result = asyncio.run(engine.init_pipeline_async("a")) + + assert result is False + assert calls == ["a"] + + +def test_valid_lang_code_succeeds_without_a_retry(engine, monkeypatch): + calls = [] + + def fake_kpipeline(lang_code="a"): + calls.append(lang_code) + return object() + + monkeypatch.setattr(kokoro_engine, "KPipeline", fake_kpipeline) + + result = asyncio.run(engine.init_pipeline_async("b")) + + assert result is True + assert calls == ["b"] diff --git a/tests/test_post_render.py b/tests/test_post_render.py new file mode 100644 index 0000000..e7673ab --- /dev/null +++ b/tests/test_post_render.py @@ -0,0 +1,109 @@ +"""Tests for kokoro_gui/audio/post.py: read-time post-processing of raw clip +segments, and process_chunk_task's `raw_output` contract that feeds it. +No Qt, no audio device.""" +import numpy as np +import soundfile as sf + +from kokoro_gui.audio import post +from kokoro_gui.audio.mixer import load_clip_samples + + +def _tone(path, seconds=0.5, rate=8000, amplitude=0.25): + t = np.arange(int(rate * seconds)) / rate + sf.write(str(path), (amplitude * np.sin(2 * np.pi * 440 * t)).astype(np.float32), rate) + return str(path) + + +# -- post_key --------------------------------------------------------------- + +def test_post_key_ignores_generation_keys_and_key_order(): + a = {"voice": "af_bella", "speed": 1.0, "volume": 1.5, "reverb_enabled": True} + b = {"reverb_enabled": True, "volume": 1.5, "voice": "af_sarah", "speed": 0.7, "out_dir": "x"} + assert post.post_key(a) == post.post_key(b) + + +def test_post_key_changes_when_any_post_key_changes(): + base = {"volume": 1.0, "reverb_enabled": False, "eq_bass": 0.0} + for key, value in (("volume", 0.5), ("reverb_enabled", True), ("eq_bass", 3.0), + ("normalize", True), ("trim_silence", True), ("pitch", 2.0), ("apply_fx", False)): + changed = dict(base, **{key: value}) + assert post.post_key(changed) != post.post_key(base), key + + +def test_extract_post_config_keeps_only_post_keys(): + cfg = {"voice": "v", "volume": 2.0, "apply_fx": True, "reverb_wet_level": 0.4, "raw_output": True} + assert post.extract_post_config(cfg) == {"volume": 2.0, "apply_fx": True, "reverb_wet_level": 0.4} + + +# -- render ----------------------------------------------------------------- + +def test_render_with_no_config_returns_the_file_as_is(tmp_path): + path = _tone(tmp_path / "a.wav") + raw, _rate = sf.read(path, dtype="float32") + assert np.array_equal(post.render(path, None, 8000), raw) + assert np.array_equal(post.render(path, {"volume": 1.0, "apply_fx": False}, 8000), raw) + + +def test_render_applies_volume_and_memoizes_per_post_key(tmp_path): + path = _tone(tmp_path / "a.wav") + raw, _rate = sf.read(path, dtype="float32") + + loud = post.render(path, {"volume": 2.0, "apply_fx": False}, 8000) + assert np.allclose(loud, raw * 2.0, atol=1e-6) + # Same key -> the cached array object, not a re-render. + assert post.render(path, {"volume": 2.0, "apply_fx": False, "voice": "ignored"}, 8000) is loud + # Different post key -> a different render. + quiet = post.render(path, {"volume": 0.5, "apply_fx": False}, 8000) + assert np.allclose(quiet, raw * 0.5, atol=1e-6) + + +def test_render_applies_the_fx_chain_when_enabled(tmp_path): + path = _tone(tmp_path / "a.wav") + raw, _rate = sf.read(path, dtype="float32") + wet = post.render(path, {"apply_fx": True, "gain_enabled": True, "gain_db": 6.0}, 8000) + assert len(wet) == len(raw) + assert np.max(np.abs(wet)) > np.max(np.abs(raw)) * 1.5 + + +def test_trim_changes_rendered_duration(tmp_path): + rate = 8000 + silence = np.zeros(rate // 2, dtype=np.float32) + tone = np.full(rate // 2, 0.3, dtype=np.float32) + path = tmp_path / "padded.wav" + sf.write(str(path), np.concatenate([silence, tone, silence]), rate) + + assert post.rendered_duration_s(str(path), None, rate) == 1.5 + assert post.rendered_duration_s(str(path), {"trim_silence": True}, rate) == 0.5 + + +def test_mixer_load_clip_samples_goes_through_post(tmp_path): + path = _tone(tmp_path / "a.wav") + raw, _rate = sf.read(path, dtype="float32") + assert np.allclose(load_clip_samples(path, 8000, {"volume": 0.5, "apply_fx": False}), raw * 0.5, atol=1e-6) + assert np.array_equal(load_clip_samples(path, 8000), raw) + + +# -- raw_output in process_chunk_task -------------------------------------- + +def test_process_chunk_task_raw_output_skips_post_processing(engine, fake_pipeline, make_config, tmp_path): + config = make_config(out_dir=str(tmp_path), volume=0.1, normalize=True) + baked = engine.process_chunk_task((0, "hello world", config), None) + raw = engine.process_chunk_task((1, "hello world", dict(config, raw_output=True)), None) + + assert baked and raw + assert baked[0]["raw"] is False and raw[0]["raw"] is True + baked_audio, _ = sf.read(baked[0]["path"], dtype="float32") + raw_audio, _ = sf.read(raw[0]["path"], dtype="float32") + # Baked: volume 0.1 then normalize to 0.98 peak. Raw: the pipeline's output untouched. + assert np.isclose(np.max(np.abs(baked_audio)), 0.98, atol=1e-3) + assert not np.isclose(np.max(np.abs(raw_audio)), 0.98, atol=1e-3) + + +def test_generate_clip_audio_marks_segments_raw(engine, fake_pipeline, make_config, tmp_path): + import asyncio + + config = make_config(out_dir=str(tmp_path), volume=0.1) + results = asyncio.run(engine.generate_clip_audio((0, "hello world", config))) + assert results and all(r["raw"] is True for r in results) + # The caller's dict is untouched (generate_clip_audio copies it). + assert "raw_output" not in config diff --git a/tests/test_text_processing.py b/tests/test_text_processing.py index 20dd57b..c5f333b 100644 --- a/tests/test_text_processing.py +++ b/tests/test_text_processing.py @@ -1,8 +1,11 @@ """Tests for parse_multispeaker_text, smart_split, extract_text_from_file -(kokoro_engine.py:459-489, 517-543, 432-457).""" +(kokoro_engine.py:459-489, 517-543, 432-457), and find_character_fx_spans +(kokoro_gui/engine/text_extraction.py's offset-preserving sibling of +parse_multispeaker_text, used by the Qt transcript editor's highlighter).""" import pytest import kokoro_engine +from kokoro_gui.engine.text_extraction import find_character_fx_spans # --- parse_multispeaker_text --- @@ -41,6 +44,60 @@ def test_parse_multispeaker_empty_segment_is_skipped(engine): assert result == [("B", None, "real text")] +# --- find_character_fx_spans (no `engine` fixture needed - module-level) --- + +def test_find_character_fx_spans_no_tags_returns_empty_list(): + # Unlike parse_multispeaker_text's [(None, None, text)] sentinel - a + # highlighter has nothing to paint when there's no tag at all. + assert find_character_fx_spans("Just plain text.") == [] + + +def test_find_character_fx_spans_single_tag_covers_tag_through_end(): + text = "[Narrator]: Hello there." + spans = find_character_fx_spans(text) + assert len(spans) == 1 + span = spans[0] + assert span.speaker_name == "Narrator" + assert span.fx_name is None + assert span.start == 0 + assert span.end == len(text) + # Unstripped: the span's slice is the literal tag plus its trailing + # space, exactly as it appears in the source text. + assert text[span.start:span.end] == text + + +def test_find_character_fx_spans_speaker_and_fx(): + span = find_character_fx_spans("[Narrator:Radio]: Hi.")[0] + assert span.speaker_name == "Narrator" + assert span.fx_name == "Radio" + + +def test_find_character_fx_spans_multiple_tags_boundaries_at_next_tag_start(): + text = "[A]: first\n\n[B]: second" + spans = find_character_fx_spans(text) + assert len(spans) == 2 + a_span, b_span = spans + assert a_span.start == 0 + assert a_span.end == text.index("[B]") + assert b_span.start == text.index("[B]") + assert b_span.end == len(text) + + +def test_find_character_fx_spans_does_not_strip_or_filter_empty_segments(): + # parse_multispeaker_text would drop the empty "[A]: " segment entirely; + # find_character_fx_spans keeps every tag's span since offset fidelity, + # not clean text, is the point. + text = "[A]: \n\n[B]: real text" + spans = find_character_fx_spans(text) + assert [s.speaker_name for s in spans] == ["A", "B"] + + +def test_find_character_fx_spans_marker_regex_length_limit(): + long_name = "A" * 150 + text = f"[{long_name}]: hello" + assert find_character_fx_spans(text) == [] + + # --- smart_split --- def test_smart_split_splits_on_paragraph_boundaries(engine): diff --git a/tests/test_time_utils.py b/tests/test_time_utils.py new file mode 100644 index 0000000..6e51e4f --- /dev/null +++ b/tests/test_time_utils.py @@ -0,0 +1,40 @@ +"""format_duration (kokoro_gui/engine/time_utils.py) - the fix for the +elapsed/ETA display "resetting" past the one-hour mark. The old +`time.strftime('%M:%S', time.gmtime(seconds))` never carried minutes into an +hours field, so e.g. 3661s ("1:01:01") printed as "01:01", indistinguishable +from 61s.""" +import pytest + +from kokoro_gui.engine.time_utils import format_duration + + +@pytest.mark.parametrize("seconds,expected", [ + (0, "00:00"), + (5, "00:05"), + (59, "00:59"), + (65, "01:05"), + (3599, "59:59"), + (3600, "1:00:00"), + (3661, "1:01:01"), + (7325, "2:02:05"), + (86400, "24:00:00"), +]) +def test_format_duration(seconds, expected): + assert format_duration(seconds) == expected + + +def test_format_duration_does_not_reset_past_one_hour(): + # This is the actual reported bug: minutes:seconds must keep climbing + # (via a growing hours field) rather than wrapping back toward 00:00. + just_under_hour = format_duration(3599) + just_over_hour = format_duration(3601) + assert just_under_hour == "59:59" + assert just_over_hour == "1:00:01" + + +def test_format_duration_clamps_negative_to_zero(): + assert format_duration(-42) == "00:00" + + +def test_format_duration_accepts_float_seconds(): + assert format_duration(90.9) == "01:30" diff --git a/tests/test_waveform_data.py b/tests/test_waveform_data.py new file mode 100644 index 0000000..c60a08b --- /dev/null +++ b/tests/test_waveform_data.py @@ -0,0 +1,72 @@ +"""Tests for kokoro_gui/qt/waveform_data.py's min/max peak decimation - pure +NumPy, no Qt import at all, mirroring tests/test_caching.py's convention of +testing this logic with zero GUI dependency.""" +import numpy as np +import pytest +import soundfile as sf + +from kokoro_gui.qt.waveform_data import compute_peaks, load_peaks_from_file + + +def test_mono_basic_peaks(): + # 8 samples split into 4 buckets of 2 each. + samples = np.array([0.0, 1.0, -1.0, 0.5, 0.2, 0.2, -0.9, 0.9], dtype=np.float32) + peaks = compute_peaks(samples, sample_rate=8, bucket_count=4) + assert peaks.shape == (4, 2) + np.testing.assert_allclose(peaks[0], [0.0, 1.0]) + np.testing.assert_allclose(peaks[1], [-1.0, 0.5]) + np.testing.assert_allclose(peaks[2], [0.2, 0.2]) + np.testing.assert_allclose(peaks[3], [-0.9, 0.9]) + + +def test_stereo_downmix_averages_channels(): + # Left channel all 1.0, right channel all -1.0 -> average is 0.0 everywhere. + left = np.ones(4, dtype=np.float32) + right = -np.ones(4, dtype=np.float32) + stereo = np.stack([left, right], axis=1) + peaks = compute_peaks(stereo, sample_rate=4, bucket_count=1) + np.testing.assert_allclose(peaks[0], [0.0, 0.0]) + + +def test_bucket_count_exceeds_sample_count(): + samples = np.array([0.3, -0.5, 0.8], dtype=np.float32) + peaks = compute_peaks(samples, sample_rate=3, bucket_count=10) + assert peaks.shape == (10, 2) + for i, value in enumerate(samples): + np.testing.assert_allclose(peaks[i], [value, value]) + for i in range(len(samples), 10): + np.testing.assert_allclose(peaks[i], [0.0, 0.0]) + + +def test_all_silence_produces_all_zero_peaks(): + samples = np.zeros(1000, dtype=np.float32) + peaks = compute_peaks(samples, sample_rate=1000, bucket_count=50) + assert peaks.shape == (50, 2) + assert np.all(peaks == 0.0) + + +def test_empty_samples_returns_zero_array(): + samples = np.zeros(0, dtype=np.float32) + peaks = compute_peaks(samples, sample_rate=1000, bucket_count=20) + assert peaks.shape == (20, 2) + assert np.all(peaks == 0.0) + + +def test_single_bucket_covers_whole_buffer(): + samples = np.array([0.1, -0.7, 0.9, -0.2, 0.05], dtype=np.float32) + peaks = compute_peaks(samples, sample_rate=5, bucket_count=1) + np.testing.assert_allclose(peaks[0], [samples.min(), samples.max()]) + + +def test_load_peaks_from_file_reads_wav_and_duration(tmp_path): + sample_rate = 8000 + duration_seconds = 0.5 + t = np.linspace(0, duration_seconds, int(sample_rate * duration_seconds), endpoint=False) + data = (0.5 * np.sin(2 * np.pi * 440 * t)).astype(np.float32) + path = tmp_path / "tone.wav" + sf.write(str(path), data, sample_rate) + + peaks, duration = load_peaks_from_file(str(path), bucket_count=64) + + assert peaks.shape == (64, 2) + assert duration == pytest.approx(len(data) / sample_rate, rel=1e-6)