4.0.0-beta.1 UI and Engine Overhaul - #51
Merged
Merged
Conversation
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.
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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The 4.0 rebuild, 40 commits on
split-Qt6-UI2. 3.2.0 was a CustomTkinter form over a singlekokoro_engine.py. This branch replaces it with:gui.pyis deleted.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.sounddevice.OutputStreammixing the arrangement in the callback), and an Export dialog that mixes down to one file with optional.srtand per-clip files.kokoro_gui/audio/post.py) shared by the transport, the exporter and the timeline waveform..tbawproject 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 undercache/projects/<id>/, crash recovery, and a welcome dialog with recents.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.pysplit intokokoro_gui/engine/mixins.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_VERSIONis4.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, and3.2.0for 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
.wavfiles 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
.tbawformat (manifest.version/requiresgates exist, but the migration path has only been exercised by me) and the install on two OSes with no packaged build.Breaking changes
presets/*.json(loaded as characters) andcustom_voices/mixes.CACHE_SCHEMA_VERSION3; keyed on voice name and content, not path). The first generate after upgrading misses every oldcache/entry.winsoundis gone; playback issounddevice/soundfile. Linux needslibportaudio2.SECURITY.mdsay so..jsonprojects from the 4.0 preview builds still open and convert to.tbawnext 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 onwindows-latestandubuntu-latest.pytest -m integration tests/integration -sfor real synthesis (needs eSpeak NG, downloads weights); each test writes a transcript next to its.wavundertests/output/for a listen.python scripts/render_screenshot.pyrenders the shell headless with a sample project;docs/assets/shell_dark.pngandshell_light.pngare its output.python -m http.server 8765 --directory docsto preview the site.[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
pytestpasses locally (the fast suite; CI runs it on Windows and Linux)_assemble_configandtests/gui_qt/test_qt_config_assembly.py