Skip to content

4.0.0-beta.1 UI and Engine Overhaul - #51

Merged
CoffeeMethod merged 45 commits into
mainfrom
split-Qt6-UI2
Sep 14, 2026
Merged

CoffeeMethod merged 45 commits into
mainfrom
split-Qt6-UI2

Conversation

@CoffeeMethod

Copy link
Copy Markdown
Owner

What

The 4.0 rebuild, 40 commits on split-Qt6-UI2. 3.2.0 was a CustomTkinter form over a single kokoro_engine.py. This branch replaces it with:

  • A PySide6 shell of dockable panels in a 2x2 grid (Transcript | Settings / Audio FX / Lexicon / Voices, Timeline | Transport) with File / Edit / Options / Workspace menus and saved layouts. gui.py is deleted.
  • A document model (kokoro_gui/daw/): canonical text with clips, tracks and characters layered on it, per-clip dirty tracking keyed on a SHA-256 segment key, and a plain-Python undo stack.
  • A multi-track timeline on a seconds axis, a sample-accurate transport (sounddevice.OutputStream mixing the arrangement in the callback), and an Export dialog that mixes down to one file with optional .srt and per-clip files.
  • Non-destructive FX: clips are raw model output; the FX chain, volume, pitch, normalize and trim are a memoized read-time stage (kokoro_gui/audio/post.py) shared by the transport, the exporter and the timeline waveform.
  • .tbaw project bundles: one zip carrying the document, generated segments, and the voice mixes / references / FX presets it uses. Background Save with atomic replace, a locked working copy under cache/projects/<id>/, crash recovery, and a welcome dialog with recents.
  • An engine abstraction (kokoro_gui/engines/) with three registered backends: Kokoro, Audio8 (zero-shot voice cloning, 44.1 kHz, one lock-serialized model) and a sine-tone Dummy that exists to keep the interface honest. Two ASR engines (Audio8-ASR, Vosk) for auto-transcribing a cloning reference.
  • kokoro_engine.py split into kokoro_gui/engine/ mixins.
  • Dark and light themes from one token module and one QSS string.
  • A four-page docs site under docs/ for GitHub Pages, and the repo hygiene files: CONTRIBUTING.md, SECURITY.md, issue and PR templates, .gitattributes, a read-only-token CI workflow with pip caching and run cancellation.

APP_VERSION is 4.0.0-beta.1. The README's changelog is a single "New in Beta 4.0.0" section; the install steps carry two clone commands (the beta tag, recommended, and 3.2.0 for the old app) and say the two are different programs.

Why

3.2.0 could turn one block of text into one file with one voice. Anything longer than a page, with more than one speaker, or that needed a second pass on a paragraph meant regenerating everything and managing loose .wav files by hand. The rebuild treats the text as a project: characters are assigned in the transcript, only out-of-date clips regenerate, FX changes never invalidate audio, and the whole thing travels as one file.

The engine abstraction is there because Audio8 needed it. It differs from Kokoro in sample rate, threading model and how a voice is specified, and wiring it in without an interface would have meant if engine_id == ... through the GUI.

This is going out as a beta rather than a final because two things are expensive to change after people depend on them: the .tbaw format (manifest.version / requires gates exist, but the migration path has only been exercised by me) and the install on two OSes with no packaged build.

Breaking changes

  • The GUI is a different program. No settings carry over from 3.2.0 except presets/*.json (loaded as characters) and custom_voices/ mixes.
  • Segment cache keys changed (CACHE_SCHEMA_VERSION 3; keyed on voice name and content, not path). The first generate after upgrading misses every old cache/ entry.
  • winsound is gone; playback is sounddevice/soundfile. Linux needs libportaudio2.
  • New dependencies: PySide6, transformers, torchaudio, safetensors, librosa, vosk, python-dotenv. The default Audio8 ASR model is CC-BY-NC-4.0; the README and SECURITY.md say so.
  • .json projects from the 4.0 preview builds still open and convert to .tbaw next to the original. Nothing before that had project files.

How to check it

  • pytest: 722 tests collected, the fast suite (Kokoro pipeline and playback mocked, Qt offscreen). CI runs it on windows-latest and ubuntu-latest.
  • pytest -m integration tests/integration -s for real synthesis (needs eSpeak NG, downloads weights); each test writes a transcript next to its .wav under tests/output/ for a listen.
  • python scripts/render_screenshot.py renders the shell headless with a sample project; docs/assets/shell_dark.png and shell_light.png are its output.
  • python -m http.server 8765 --directory docs to preview the site.
  • Manual: new project, paste a [Narrator]: / [Alice]: script, Generate, drag a clip, move an FX slider mid-play, Save, kill the process, relaunch, take the recover prompt.

Not in this PR

macOS CI, a packaged build, ASR-anchored import of existing recordings, cache eviction for CACHE_DIR. All tracked for after the beta.

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

kokoro_engine.py (1062 -> 100 lines) and gui.py (1759 -> 833 lines)
are now slim core modules composing their classes from mixins in a
new kokoro_gui/ package:
- kokoro_gui/engine/: audio_fx, lexicon, srt, text_extraction,
  voices, presets, caching, conversion, jit
- kokoro_gui/ui/: lexicon_tab, fx_tab, mixing_tab, generation_tab
kokoro_engine.py and gui.py keep every module-level name the test
suite monkeypatches (CACHE_DIR, CUSTOM_VOICES_DIR, playback,
get_thread_pipeline, KPipeline, pypdf/ebooklib/epub, CONFIG_FILE,
PRESETS_DIR, FX_PRESETS_DIR, KokoroEngine, messagebox, filedialog,
ctk). Extracted mixins that need one of these import the owning
module and access the name qualified, at call time (e.g.
`kokoro_engine.CACHE_DIR`), never via `from module import name`, so
monkeypatch.setattr(...) in tests keeps working unchanged.
Verified by running the full test suite after each extraction step
(109/109 passing throughout) and checking for MRO/method-name
collisions across both mixin sets (none found).
base.py — ConfigField/ConfigFieldType, EngineCapabilities, VoiceInfo, and the TTSEngineBackend / SupportsVoiceMixing Protocols. I deliberately left generate()/start_jit() out of the Protocol (documented in the module docstring) rather than inventing a uniform async surface KokoroEngine's callback-driven AsyncLoopThread doesn't actually have — per the plan's own step 5, that's premature until a second backend exists to validate it against.
registry.py — register_engine/get_engine/list_engines.
kokoro.py — KokoroBackendAdapter, a thin composition wrapper around an existing KokoroEngine instance. get_config_schema() reflects today's actual fields (voice, speed, pitch, lang_code, split_pattern, format, num_threads, caching, lexicon); get_voices() covers the genuinely-engine-owned part of the voice catalog (custom .pt files); mix_voices/cancel delegate straight through. Registers itself as "kokoro" on import.
Changed
gui.py — constructs self.backend right after self.engine, and the Mixing tab (raw voice-tensor math, Kokoro-specific) is now gated on self.backend.capabilities.supports_voice_mixing instead of always shown — a no-op today since Kokoro supports it, but ready for a future non-mixing backend.
kokoro_gui/ui/generation_tab.py — split-pattern presets, output-format choices, and the speed slider's bounds now come from the adapter's schema instead of being hand-typed a second time in the widget-building code. voice/lang_code intentionally stay GUI-resolved (still GUI display data, not engine data — documented in the schema's docstring); pitch/volume/num_threads are left hand-built for now as a smaller, low-risk follow-on.
kokoro_gui/engines/dummy.py — DummyEngine + DummyBackendAdapter, registered as "dummy". It generates short sine tones instead of real speech, but reuses every mixin that turned out to be genuinely model-agnostic (FX chain, batch/JIT conversion orchestration, lexicon, presets, SRT export, text extraction) unmodified — it's real proof the workstream-1 abstraction isn't over-fit to Kokoro, not just a mock. It deliberately skips the shared segment cache (writing dummy tones into CACHE_DIR under the same text|voice|speed|lang_code hash the real engine uses would let a later Kokoro run collide with a cached tone — the exact cross-engine cache bug workstream 2 exists to fix).

Enabling change: one polymorphic pipeline hook

The only truly Kokoro-specific call inside the generic mixins was kokoro_engine.get_thread_pipeline(lang_code). I added KokoroEngine.get_thread_pipeline() (kokoro_engine.py) and switched the two hard-coded call sites in caching.py and conversion.py to self.get_thread_pipeline(...) — zero behavior change for Kokoro (still resolves via the same monkeypatchable module-level function), but now any backend can supply its own.

GUI: engine switching

gui.py gained an "Engine:" dropdown in the header, listing everything in kokoro_gui/engines/registry.py. Selecting one calls switch_engine(), which:

refuses (with a warning) if a job is currently running,
builds a fresh instance of the chosen backend, rewires on_progress/on_status/on_finish onto it, stops the old backend's worker thread,
re-syncs the Mixing tab via _sync_mixing_tab() (added/removed live using CTkTabview.add/.delete) based on the new backend's capabilities.supports_voice_mixing — switching to "dummy" now visibly makes the Custom Voice tab disappear.

It does not re-render the Generation tab's schema-driven fields for the newly active backend — that's real Qt-migration territory per the plan, noted explicitly in the code and the plan doc.
CACHE_SCHEMA_VERSION = 2 — bump this on any future change to the hash composition; old entries just stop matching.
compute_cache_key(text, config, eff_speed, lang_code, engine_id="kokoro", engine_version=None) — a standalone, unit-testable SHA-256 replacement for the old MD5 text|voice|speed|lang_code scheme. Composition: schema_version|engine_id|engine_version|text|voice|voice_fingerprint|speed|lang_code. split_pattern/FX/format/normalize/trim stay out of the hash exactly as before.
voice_fingerprint(voice_ref) — standard voices are fingerprinted by name (they don't change); custom voices (resolved to an absolute .pt path under CUSTOM_VOICES_DIR) are fingerprinted by SHA-256 file content, cached per-mtime so a batch run doesn't re-hash the same file per chunk. This is the fix for the "remix and re-save a custom voice under the same name" staleness case the plan called out.
get_engine_version(engine_id) — importlib.metadata.version("kokoro") for "kokoro", "unknown" for anything else (honest about what's actually implemented today).
gui.py

start_conversion's config dict now sets config['engine_id'] = self.backend.id, so the cache key can actually tell backends apart. process_chunk_task falls back to "kokoro" when the key is absent (e.g. tests/callers predating this).
A parallel Qt frontend under kokoro_gui/qt/ that talks to the exact same KokoroEngine/backend registry as the Tk app — zero edits to gui.py, kokoro_gui/ui/*.py, or kokoro_gui/engine*/:

kokoro_gui/qt/spec.py — pure-data constants mirroring Tk's field lists (BASE_KEYS, the 43-key FX preset schema, voice/language tables), cross-checked against Tk at runtime by tests rather than shared code
kokoro_gui/qt/app.py — QtTTSApp(QMainWindow): engine/backend construction, config assembly, preview/start/cancel lifecycle, autosave to config_qt.json
kokoro_gui/qt/docks/ — Generation (schema-driven via SchemaFormWidget), FX, Mixing (capability-gated), Lexicon docks
kokoro_gui/qt/signals.py — cross-thread callback bridge replacing Tk's self.after(0, ...) pattern
main_qt.py — entry point (python main_qt.py, ships alongside python main.py)
One deliberate improvement over Tk: engine switching now actually rebuilds the Generation dock's schema fields and toggles the Mixing dock — fixing the gap gui.py's own switch_engine docstring names

Presets (presets/*.json, presets/fx/*.json) are shared between both frontends by design; app settings are not (separate config_qt.json).

Testing

tests/gui_qt/ — 31 new tests (self-skip via pytest.importorskip if PySide6 isn't installed), including a test that captures Tk's live assembled config dict and asserts it's identical to Qt's, and cross-frontend preset load/save tests. PySide6/pytest-qt are optional installs (requirements-qt.txt/requirements-qt-test.txt), not forced on Tk-only users.
…w Qt-only. Summary:

Removed

gui.py, main.py (old Tk entry point), kokoro_gui/ui/ (fx/generation/lexicon/mixing tab builders)
config.json (Tk's autosave file — dead now)
requirements-qt.txt / requirements-qt-test.txt (merged into the main requirement files, see below)
Tk-only tests: tests/test_gui_config_assembly.py, tests/test_gui_handlers.py, tests/test_gui_settings.py
Cross-frontend parity tests that only made sense with two GUIs (test_assembled_config_matches_tk_live_capture, the Tk↔Qt preset round-trip tests, four tts_app-based tests in tests/test_engine_backend.py) — all fully superseded by tests/gui_qt/'s own coverage

Renamed / promoted

main_qt.py → main.py — the sole entry point now (python main.py / run.bat)
PySide6 moved from optional requirements-qt.txt into requirements.txt as a regular dependency; pytest-qt likewise into requirements-test.txt

Updated

tests/conftest.py — dropped the tts_app/Tcl-Tk fixture plumbing, kept StubEngine (still used by the Qt test suite)
.github/workflows/tests.yml — no more Xvfb install/wrapping (Qt's QT_QPA_PLATFORM=offscreen never needed a display; Xvfb was only for the old Tk windows)
README.md, ROADMAP.md, CLAUDE.md, PLAN_qt_and_engine_abstraction.md — rewritten to describe Qt as the only frontend, with the plan's checklist recording the retirement
Stale gui.py/CustomTkinter comments and docstrings cleaned up across kokoro_gui/qt/*.py and kokoro_gui/engines/*.py

Verified

Full pytest suite: 131 passed, 0 failed
main.py launches QtTTSApp correctly with the real KokoroEngine (smoke-tested headless)
Deleted the dead thread_row/self.threads_spin QSpinBox from "Processing Options" and its valueChanged wiring.
Fixed set_ui_state() in app.py:332, which referenced threads_spin.setEnabled(...) — now disables the real control via self.generation_dock.schema_form.widget_for("num_threads").
Removed a start_conversion() guard in app.py:395-398 that clamped threads_spin.value() to ≥1 — redundant even before, since the schema QSpinBox already has setRange(min=1, max=32) baked in from the ConfigField, so it can never go below 1 anyway.
Dropped the now-unused QSpinBox import.
…n-Kokoro backend for Audio8-TTS-Preview-0.6b, registered alongside Kokoro/Dummy and selectable from the existing engine picker. Handles the two genuine differences from Kokoro: 44.1kHz output (generalized ConversionMixin/CachingMixin call sites that used to hardcode 24000) and a shared, lock-serialized model singleton instead of one model per worker thread (a 0.6B model per thread would be wasteful).

Voice Reference tab (kokoro_gui/qt/docks/voice_clone_dock.py) — wired up the previously-unused supports_voice_cloning capability flag to show/hide a new dock (mirroring how the Mixing dock already does this for supports_voice_mixing). Lets you browse a reference WAV, auto-transcribe it, edit the transcript, and save it under a name that then shows up in the normal Voice dropdown.

Auto-transcript module (kokoro_gui/engine/asr.py) — wraps Audio8-ASR-0.1B, runnable both from the dock's button and standalone via python -m kokoro_gui.engine.asr <wav>.

Supporting changes: extended compute_cache_key with a backward-compatible extra param so a reference's transcript is part of the cache key; fixed the Generation dock's language/voice dropdowns to be backend-aware instead of hardcoded to Kokoro's tables; gated the JIT-streaming toggle on the active backend's capability; added transformers/torchaudio/safetensors to requirements.txt.

Testing: 27 new tests across engine-contract, ASR, caching (including a multi-segment cache-correctness regression test I caught and fixed during implementation), and GUI dock behavior — all mocking the real models, never touching the network.
New "Model" config group with max_new_tokens (default 1024, capped at 2048 to match the model's real max_seq_len — anything higher was already being silently clamped), temperature (0.8), top_p (0.95), top_k (50) — your requested values are now the defaults, and all four are user-adjustable in the Generation dock (schema-driven, no GUI code needed).
generate_segment no longer hardcodes these in the model.generate_audio(...) call — it reads instance attributes (self.max_new_tokens/temperature/top_p/top_k), same pattern as the existing cache_reference_codes toggle.
process_chunk_task sets those attributes from config each run, with the same defaults as a fallback for direct calls that bypass it.
Also folded them into the segment-cache key (compute_cache_key's extra=) — these params change what actually gets generated, so if segment caching is on and you change temperature/top_p/etc., it now correctly regenerates instead of silently serving audio produced under old settings.
docs/index.html — a single self-contained GitHub Pages landing page. No build step, no dependencies beyond a Google Fonts link, so it just works once GitHub Pages is pointed at /docs.
…d CSS/nav/theme-toggle got extracted out to docs/assets/, so this file shrank a lot even though content was added. Plus: the scroll-margin-top fix (so anchor jumps like "Quick start" land clear of the sticky nav), corrected FX/signal-chain/theme copy to match the actual code, new nav links to the two guide pages, the eSpeak NG system-dependency install block, and the slower hero spectrum animation.

docs/WEBSITE_ROADMAP.md — 38 lines, updated to describe the three-page structure, the shared-asset split, and the stale-content corrections found while building settings.html.
docs/assets/site.css, docs/assets/site.js — shared tokens, nav, theme toggle, and reference-page components used by all three pages.
docs/scripting.html — the inline [Preset:FXPreset]: scripting guide.
docs/settings.html — the full settings reference, including the Audio8 language/speed gotcha and the JIT streaming explanation.
…durations with time.strftime('%M:%S', time.gmtime(seconds)). gmtime tracks hours internally, but %M:%S only ever prints minutes-mod-60 — past 3600 seconds the hours field kept climbing invisibly while the displayed clock wrapped back through 00:00, which read as a reset. Added time_utils.py with a format_duration() that grows into H:MM:SS instead, used in both conversion.py (ETA) and app.py:361 (elapsed).

History-based ETA prediction: added stats.py, a small per-engine_id store (generation_stats.json, mirroring how CACHE_DIR is already tracked) that keeps the last 20 completed generations' chars/words/duration for each backend. conversion.py now:

Records every run's actual throughput in a finally block (even a cancelled run — partial chars/duration are still a valid data point), keyed by config['engine_id'], so Kokoro/Dummy/Audio8's very different speeds never blend together.
Uses that history to show a real ETA from the very first progress tick (previously "--:--" until enough of the current run had completed to extrapolate from).
Blends the historical rate with the current run's own observed rate as more of it completes, so a slow/fast first chunk can't swing the estimate — trust shifts fully to the live rate by ~15% progress.
Falls back to the old same-run-only extrapolation when there's no history yet for that engine.
…s that together close the entire preset-trust-boundary exploit chain:

voices.py / audio8_tts.py — resolve_voice_path's unmatched-name fallback now returns the sanitized basename instead of the raw string. A no-op for real voice names (no path separators); closes the UNC-path/NTLM-leak and arbitrary-.pt-read vectors. Verified against the audit's own exploit strings above.
conversion.py / jit.py — the four seg_config.update(preset) / .update(fx_preset) sites (plus the same pattern in generate_preview) now go through a new filter_allowed_keys() helper in presets.py, whitelisted to exactly the keys the GUI's own _save_preset_dialogs write. out_dir/filename/time_id can no longer be smuggled in via a preset JSON.
audio_fx.py / caching.py — both 2 ** (pitch/12.0) sites now clamp through a new clamp_pitch_semitones() to the GUI's own -12..12 spinbox range, matching intended behavior exactly for any legitimately-typed value and turning out-of-range preset values into a safe clamp instead of OverflowError/multi-GB resample allocation.
app.py — the GUI's free-text filename field is now sanitized with os.path.basename() before entering the config dict, matching the pattern already used for voice/preset names.
editing, undo, and batch generation

Implements the "DAW for text" redesign end to end (Claude/PLAN_daw_ui_ux_redesign.md),
minus ASR-anchored audio import, which is left for a follow-up change.

Foundational data model
- New kokoro_gui/daw/ package: Document/Clip/Segment/Track/Character,
  cache-hash-based dirty tracking, document.json save/load, and a
  presets -> Character migration path. Deliberately Qt-free so the
  model stays testable without a QApplication.

Transcript panel
- The Direct Text editor is now a syntax-highlighting TranscriptEditor
  that paints each run of text by its assigned character/FX, offers a
  Characters menu for reassigning a selection's voice/FX, and carries
  character metadata through copy/paste (with a setting to toggle
  whether a paste splits off its own run or inherits the destination's).

Waveform + timeline
- A standalone single-track waveform-rendering spike (peak decimation,
  wall-clock playhead) grew into a real multi-track TimelineView/
  TimelineDock: one lane per character track, clips rendered as
  colored blocks, right-click Generate/Play per clip wired to the
  existing synthesis engine and cache.

Selection sync
- New SelectionModel is the single source of truth for what's
  selected: a clip, a character (via timeline track-lane labels), a
  plain text range, or nothing. Click-to-select on timeline clip
  blocks and lane labels; cursor movement in the transcript selects
  the covering clip. Both panels stay in sync with no ping-pong.

Settings panel rescoping
- New SettingsDock renders whole-document defaults, a selected clip's
  effective config, or a selected character's preset, reusing the
  existing SchemaFormWidget. Edits write to the right place (app
  settings / clip.overrides / character.preset_data); fields with no
  per-clip storage render disabled rather than hidden.

Consolidated action bar + batch generation
- New KokoroEngine.generate_dirty_clips regenerates every stale clip
  in one action, reusing the existing per-clip generation primitive
  with bounded concurrency and cancel-safe queuing. Generate now
  dispatches the batch path once a document has clips, falling back
  to the original whole-document pipeline for documents that don't.

Undo/redo
- New plain-Python UndoStack/Command pair (kokoro_gui/daw/undo.py,
  deliberately not Qt-based so the data model stays framework-free)
  backing text edits and Characters-menu assignments. Added the app's
  first menu bar (Edit > Undo/Redo).

Per-clip FX button
- Clip.fx_override now actually applies during generation, wins over
  a character's FX preset, and is editable per clip from a new button
  on each timeline block (50%/90% opacity to show whether FX is
  active).

Real time-based clip positioning
- A generated clip's rendered width now grows to fit its real audio
  duration; ungenerated clips are unaffected. Position stays
  document-order-anchored to keep the transcript and timeline in
  sync.

Auto-split on generation
- New action turns a [Speaker:FX]-tagged (and optionally
  paragraph-split) document into clips automatically and generates
  them in one step.

Timeline editing gestures
- Drag a clip onto a different character's track to reassign or just
  move it (with a confirmation prompt when characters differ).
- Drag within a single clip's waveform to carve out and replace a
  sub-range with fresh TTS under any character, with an editable
  transcript before regenerating.

516 tests passing, no known regressions.
Clip no longer has start_offset/end_offset. Document.runs (a list of tagged text spans) is now authoritative; Document.text is a derived property. assign_character_to_range/replace_text operate on the run list directly. document.json gets a new "runs" shape, with a transparent migration path for old offset-based files (verified against the real project document.json). Undo commands (AssignCharacterCommand/TextEditCommand) now snapshot/restore the whole run list rather than replaying edits in reverse.

2. Transcript editor rewrite — transcript_editor.py, undo_coordinator.py
Typing now rides Qt's native undo; character/FX assignment stays on the custom stack, coordinated by a new UndoCoordinator so Ctrl+Z always reverts whichever happened most recently. Highlighting became a much simpler single-pass ClipHighlighter — I deliberately kept it as a QSyntaxHighlighter overlay rather than the plan's literal "paint via QTextCharFormat" approach, because I verified QTextDocument.setUndoRedoEnabled() wipes undo history on every toggle, which would've destroyed typing history every time a clip got tagged. [Speaker:FX]: shorthand now converts into a real tagged run on completing the line (Enter), or on losing focus for a trailing line.

3. Left gutter — new TranscriptGutter in the same file: "Character: X" (+ FX marker) labels that only redraw where they change, clickable to reassign via a dropdown.

4. Timeline/app repointed at Document.clip_extent() instead of stored offsets.
Clip no longer has start_offset/end_offset. Document.runs (a list of tagged text spans) is now authoritative; Document.text is a derived property. assign_character_to_range/replace_text operate on the run list directly. document.json gets a new "runs" shape, with a transparent migration path for old offset-based files (verified against the real project document.json). Undo commands (AssignCharacterCommand/TextEditCommand) now snapshot/restore the whole run list rather than replaying edits in reverse.

2. Transcript editor rewrite — transcript_editor.py, undo_coordinator.py
Typing now rides Qt's native undo; character/FX assignment stays on the custom stack, coordinated by a new UndoCoordinator so Ctrl+Z always reverts whichever happened most recently. Highlighting became a much simpler single-pass ClipHighlighter — I deliberately kept it as a QSyntaxHighlighter overlay rather than the plan's literal "paint via QTextCharFormat" approach, because I verified QTextDocument.setUndoRedoEnabled() wipes undo history on every toggle, which would've destroyed typing history every time a clip got tagged. [Speaker:FX]: shorthand now converts into a real tagged run on completing the line (Enter), or on losing focus for a trailing line.

3. Left gutter — new TranscriptGutter in the same file: "Character: X" (+ FX marker) labels that only redraw where they change, clickable to reassign via a dropdown.

4. Timeline/app repointed at Document.clip_extent() instead of stored offsets.
CoffeeMethod and others added 10 commits August 30, 2026 15:29
Rebuilds the Qt shell to match the original wireframe (PLAN_ui_shell_redesign.md,
decisions UI1-UI14). The data model is untouched; this is the panel layer on top.

Shell
- Menu bar: File (New/Open/Recent/Save/Save As/Import Text/Export), Edit (undo,
  clipboard, Characters...), Options (Engine, Device, Theme, copy/paste behavior,
  JIT), Workspace (Advanced/Simple/Reset). Toolbar and central action bar removed.
- Docks in a 2x2 grid: Transcript | Settings/Audio FX/Lexicon/Voices tabs over
  Timeline | Transport. Named layouts persist under settings["workspaces"]
  (kokoro_gui/qt/workspace.py); the old dock_state/geometry keys migrate.
- Central widget is hidden with an Ignored size policy. A fixed 0x0 widget caps
  the height of the row it shares, which made the timeline impossible to enlarge.
- Light/dark palettes in kokoro_gui/qt/theme.py (Fusion for both; the native
  Windows style ignores most palette roles). Custom-painted widgets read tokens.
- Options > Device writes settings["device"]; KokoroEngine.init_pipeline_async
  takes it and passes it to KPipeline.

Transcript (transcript_dock.py replaces generation_dock.py)
- Character and FX combos above the editor, reflecting the caret's clip and
  reassigning the selection / clip on change.
- Gutter labels once per (character, fx) change on two lines, with a play
  button per dirty clip that runs a scoped generate. Dirty runs get a dashed
  underline; split rules mark clip boundaries and planned auto-split cuts.
- Input Source tabs, file row, legacy preset row and Auto-Split row removed.
  Import Text lives on the File menu, Auto-split on the Generate button's menu.
- Copy produces a plain QMimeData so the character MIME type survives
  in-process paste/drag; settings["character_fx_copy"] can turn it off.
- Placing the caret inside a clip no longer selects the whole clip's text.

Timeline
- kokoro_gui/daw/arrangement.py: clips placed end to end in text order across
  tracks, real duration once generated, otherwise estimated from
  generation_stats.json (15 chars/s fallback). Single source of truth for the
  view, the transport and the exporter.
- Seconds axis with ruler, playhead, fixed track-header column, Ctrl+wheel zoom.
  Estimated clips draw dashed with no waveform; FX chip opacity follows state.
- Drag pins clip.timeline_timestamp (SetClipTimestampCommand); dropping at or
  before the text predecessor also moves the text (MoveClipBeforeCommand).
  Sub-range TTS replacement is now Shift+drag.

Playback
- kokoro_gui/audio/transport.py: one sounddevice.OutputStream mixing the
  arrangement in the callback (kokoro_gui/audio/mixer.py, plain gain sum,
  clipped), position from the frame counter, published at 30Hz. Play/pause/
  stop/seek/loop, Space (outside the editor) and Ctrl+Space toggle.
- SelectionModel.playing_clip_id drives the transcript highlight and scroll
  without touching the user's selection.
- playhead_calc.py and WaveformPanel removed; WaveformItem kept.

Export and projects
- kokoro_gui/daw/mixdown.py + docks/export_dialog.py: offline mixdown to
  wav/mp3/flac/ogg, optional .srt from PlacedClip timings, optional per-clip
  files named <base>_<index:03>_<character>.<ext>. Output/format/subtitle
  settings moved here from the Settings tab and persist per project.
- kokoro_gui/qt/project.py: .json projects in the document.json shape plus a
  project_settings block; last_project/recent_projects; New inherits characters;
  Import Text asks add vs new; .tbaw recognized but NotImplementedError.

Audio FX tab follows the selection like Settings: project state, character
preset file (confirmed once per session), or a debounced per-clip override.
SetClipFxCommand records the preset name in clip.overrides["fx_preset"].

Edit > Characters... dialog edits name, color, voice and FX preset.

Tests: 631 passing. New coverage for arrangement, mixer/transport, mixdown,
project files, shell/menus/workspaces/theme, transcript header/gutter, FX
scoping, export, characters dialog, timeline moves. Timeline view tests
rewritten for the seconds axis; app-attribute references repointed
(app.transport_dock, app.editor).

Docs: README "New in Beta 4.1.0", docs site pages and screenshots
(scripts/render_screenshot.py renders the shell headless), CLAUDE.md GUI
section, ROADMAP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…raw segment files. Nothing about them touches the clip or its dirty state.

New modules

kokoro_gui/audio/post.py: POST_KEYS, post_key(), render(path, post_config, rate) memoized by (path, mtime, post_key, rate). It calls the same process_audio the old path did, just later.
kokoro_gui/qt/fx_resolve.py: the single resolver (project FX → character preset → clip preset → clip.fx_override). Both _assemble_clip_config and the Audio FX tab use it, which also fixes project-scope FX never reaching clips.

Generation side

generate_clip_audio sets config["raw_output"]; all three backends' process_chunk_task honour it and skip process_audio. Whole-document generation (no clips) is unchanged.
Segment.raw (default True). serialization.py stamps False on saved segments that predate the flag, and is_clip_dirty reports those dirty so they regenerate once (cache hit) instead of getting FX twice. Your current document.json clips will show out of date one time.

Read side

Transport (ScheduledClip.post_config), export (mixdown(post_config_for_clip=...)), the timeline waveform, and the timeline's right-click Play (now seeks the transport instead of playing the raw file) all read through post.render.
compute_arrangement takes a clip_duration callable; the app passes one that measures rendered length, since trim and pitch change it. Every live-document arrangement goes through app.build_arrangement().
FX tab project-scope edits and the Settings tab's volume/pitch/normalize/trim/Apply now call refresh_timeline() (300 ms debounce on the FX tab). Transport.load keeps position, so a change is audible mid-play.

The apply_fx question I settled as: a clip with its own fx_override counts as FX-on even when its character's preset says off, unless the clip carries an explicit overrides["apply_fx"] = False. Master "Apply" in the Settings tab still gates everything.
welcome_dialog.py (new): WelcomeDialog with the recent list (open project forced to the front, missing files greyed with "(missing)" and unselectable), a details pane (path, modified, characters, clips), Resume/Open, New project, New from text file, Open other, right-click "Remove from list", Clear list, and the "Show at startup" checkbox. Opened with open() so the engine's init status keeps flowing underneath and tests can drive it.
project.py: clear_recent() and project_summary() (reads the raw JSON, no Document build).
app.py: show_welcome() / show_welcome_if_enabled(), File ▸ Welcome… after Recent. main.py is the only launch trigger, so the qt_app fixture and render_screenshot.py never see a dialog.
spec.py: show_welcome: True default.
Tests: test_welcome_dialog.py (9 cases), two pure-function tests in test_project_files.py, and the File menu expectation in test_shell.py.
…le name

Prerequisite for the .tbaw bundle (Claude/PLAN_tbaw_bundle.md, section 9).

- caching.segment_key(text, config, backend) is the one hash: what
  Segment.cache_key stores, what dirty.py recomputes and what a project-dir
  segment file is named. The voice enters as basename + content fingerprint,
  never a path, so a key is the same on every machine. CACHE_SCHEMA_VERSION
  bumps to 3; compute_cache_key gains schema_version= for migration.
- Every backend runs the same CachingMixin.process_chunk_task (Dummy and
  Audio8 drop their hand-rolled copies; SAMPLE_RATE is read off the
  instance). segment_naming: "cache_key" names files by key in out_dir,
  leaves CACHE_DIR alone, reserves the target with O_EXCL and bumps the
  clip's take instead of overwriting a present file (TB8). Results carry
  take/cache_key/engine_version and the dock stamps segments from them.
- Backend hooks: engine_version, cache_key_extra, resolve_voice_file
  (engine + adapter), collect_project_assets and on_project_opened
  (adapter, BackendHooksMixin). Audio8 keys on its model id and transcript.
- Model dataclasses gain an `extra` dict; serialization round-trips unknown
  fields and stripped preset keys. Segment.engine_version stored; the dirty
  check keys with it while the file exists (TB9) and a missing file is
  dirty (TB11). Document.segment_key_fn is the app's memoized closure over
  _assemble_generation_config, which _assemble_clip_config builds on.
- Project-local asset search for Kokoro mixes and Audio8 references.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The project format is one zip (Claude/old/PLAN_tbaw_bundle.md, grill
TB1-TB15): manifest.json (format/version/requires, project_id, stats,
engines[id].version + meta, asset hashes), document.json with
bundle-relative audio paths, project.json, audio/generated/<segment_key>_<i>
stored, fx/ and engines/<id>/ assets collected from the document through
each backend's collect_project_assets. Unknown entries, manifest keys and
document fields round-trip through an older reader.

- Live project is cache/projects/<project_id>/ under an OS file lock with
  a session.json (source_path, zip size/mtime, saved_digest, dirty,
  asset_index). Clips generate straight into it with cache-key naming;
  autosave writes document.json/project.json there and sets dirty by
  content digest; Save plans on the GUI thread and writes the zip on a
  background thread behind is_busy (free-space check, .tmp + replace with
  a Windows retry, unknown entries copied through); Open validates entries
  (zip-slip, drive-relative, symlinks, torch >= 2.6 for .pt), extracts
  audio on a thread with the editor read-only, offers recovery of a dirty
  session found by project_id (keep session / take file / cancel), and
  reuses a clean matching dir so Resume is fast.
- Close asks Save / Discard / Cancel when dirty; a clean close GCs
  unreferenced segment files and evicts every project dir but the last
  project's. Open sweeps dirs whose file is gone.
- .json projects migrate on open: a segment whose stored key matches the
  legacy (schema 2) key and whose file exists is copied in and rekeyed;
  the rest regenerate. The repo-root document.json migrates on launch.
- Assets resolve project-dir first (Kokoro mixes, Audio8 references, FX
  presets); the Export dialog holds the bundle options (TB14).
- project_summary reads only the manifest; the welcome dialog shows audio
  length and engines. pytest.ini registers `slow` (the >4 GB round trip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e levels (window / panel / raised) plus three new tokens (border, hover, accent_hover). apply() now also sets the app font (Segoe UI → Inter → Noto Sans, 10pt; transcript 11pt) and stylesheet(pal), ~150 lines of QSS rendered from the tokens: flat dock titles, borderless group boxes with muted headings, 4px-radius inputs and buttons with accent focus rings, tabs underlined with an accent bar, 10px flat scrollbars, a 20px flat progress line with a translucent accent chunk, round slider handles, padded menus. Chevrons and the checkbox tick are five tiny SVGs in kokoro_gui/qt/assets/ because QSS image: only takes files.

Transport (transport_dock.py). Play/pause/stop are round flat tool buttons with glyphs painted by the new icons.py (no icon dependency, retinted on theme change; play is accent-coloured). Generate is the one filled primary button, Cancel is flat text. Loop stays a text toggle.

Timeline and colours. Clips have 4px corners, an outline in the character colour's darker shade, the waveform in that same shade instead of the fixed blue, and a black-or-white label picked by luminance (timeline_view.py). Transcript highlights are a tint (alpha 90) rather than a solid block, so the same hex reads on both panels. DEFAULT_HIGHLIGHT_PALETTE is eight hues at one lightness and is loaded into the colour picker's custom slots in the Characters dialog. Also fixed a pre-existing "EQ _Filters" mnemonic-underscore in the FX dock group titles.
Comment thread kokoro_gui/engine/asr.py Fixed
Comment thread kokoro_gui/engine/asr.py Fixed
Comment thread kokoro_gui/qt/project.py Fixed
Comment thread kokoro_gui/qt/project.py Fixed
Comment thread kokoro_gui/qt/project.py Fixed
CoffeeMethod and others added 5 commits September 13, 2026 05:07
CodeQL (local threat model) flagged five py/path-injection sinks on the
PR. Three were real enough to fix:

- finish_open: a document.json segment naming a file outside the project
  dir now reads as missing instead of pointing at that file, which the
  next Save would have copied into the bundle. The path is resolved and
  checked against the project dir before any file access.
- plan_save: only files inside the project dir are bundled; the "outside
  the project dir" branch had no live caller since migrate_segments copies
  a .json project's audio in.
- asr._ensure_pcm16_mono returns the temp path separately, so the Vosk
  cleanup only ever removes the file it created, never the caller's wav.

extract_small also skips a bundle entry named session.json or lock (or a
.tmp sibling): lock is held open during Open and session.json is what the
sweep and the recover prompt trust.

The ubuntu-latest leg failed importing PySide6.QtGui (libEGL.so.1 missing
on the 24.04 runner image): the apt step now installs libegl1, libgl1,
libxkbcommon0, libfontconfig1 and libdbus-1-3 alongside libportaudio2.

The two remaining alerts (the Vosk model folder's isdir, the sweep's
exists on session.json's source_path) read paths the user chose on
purpose and have nothing to contain them against; they need a dismissal
or a threat-model change on the repo, not code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_transport_loop_wraps_instead_of_stopping left its Transport playing,
so its 30Hz position QTimer stayed active after the test. Every later
test's pytest-qt setup calls processEvents, which kept ticking it, and
once the object became garbage (the next fake_factory reset dropped the
FakeStream that held its callback) the cyclic GC deleted the QObject
under a running tick. On the Windows CI leg that surfaced as an access
violation inside processEvents during tests/test_post_render.py.

A make_transport fixture now builds every Transport in the file and
stops each one at teardown; fake_factory also clears FakeStream.instances
on the way out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
asr._get_vosk_model no longer probes the model folder before handing it
to vosk.Model: the library fails for a missing folder the same way it
fails for one without model files, and one error now names the path and
what belongs there. The test's fake vosk raises for a missing folder like
the real one.

project.sweep_orphan_dirs accepts a session's source_path only when
normpath leaves it absolute (record_save and finish_open always write
one); a relative, drive-relative or non-string value is a corrupt session
and no longer drives a delete, or a TypeError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ubuntu-latest leg died with "Illegal instruction (core dumped)"
before printing a single test. pedalboard's 0.9.24 and 0.9.25 Linux
wheels are built with -march=native on the release host and SIGILL at
import on AMD Zen 3, which is what GitHub's ubuntu runners are
(spotify/pedalboard#454; 0.9.23 is reported to import there). main's
last green run happened to get 0.9.24 on a runner that survived it.

No traceback appeared because tests/conftest.py, which imports torch,
kokoro_engine and pedalboard, is loaded as an initial conftest before
pytest_configure switches on pytest's faulthandler. The workflow now runs
python -X faulthandler -m pytest so the next native crash says where.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CoffeeMethod
CoffeeMethod merged commit 4da4592 into main Sep 14, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants