diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..61dac09 --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +export VIRTUAL_ENV="$PWD/.venv" +export PATH="$VIRTUAL_ENV/bin:$PATH" diff --git a/.gitignore b/.gitignore index 287f949..964ef05 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ __pycache__/ *.pyc *.pyo .venv/ +build/ +dist/ +*.spec # Audio files *.wav diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fc88efd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.defaultInterpreterPath": ".venv/bin/python", + "python.terminal.activateEnvironment": true +} \ No newline at end of file diff --git a/README.md b/README.md index 6df526a..1fa1829 100644 --- a/README.md +++ b/README.md @@ -34,14 +34,24 @@ requirements.txt setup_windows.bat ← one-click Windows setup run_gui.bat ← launch GUI on Windows run_audiobook.bat ← generate audiobook on Windows -``` +--- + +## Setup (Windows - Easiest for Non-Tech Users) + +1. **Download** the project as a ZIP file from GitHub +2. **Extract** the ZIP to a folder on your computer (e.g., `C:\audiobook-creator`) +3. **Double-click** `setup_windows.bat` and wait for it to finish installing everything (may take 10-20 minutes) +4. **Double-click** `run_gui.bat` to launch the Proper Noun Player GUI +5. **Double-click** `run_audiobook.bat` to generate audiobook chapters + +That's it! The setup script handles Python installation, virtual environment, and all dependencies automatically. --- ## Setup (Linux / Mac) ```bash -python3.11 -m venv .venv +python3.12 -m venv .venv source .venv/bin/activate pip install torch --index-url https://download.pytorch.org/whl/cu124 # CUDA 12.4 pip install -r requirements.txt diff --git a/SETUP_WINDOWS.md b/SETUP_WINDOWS.md index 36ddb67..8f6c2ad 100644 --- a/SETUP_WINDOWS.md +++ b/SETUP_WINDOWS.md @@ -18,7 +18,7 @@ Follow the steps in order and you will be generating audiobook chapters with you ## Step 1 — Install Python 1. Go to **https://www.python.org/downloads/** -2. Click the big yellow **"Download Python 3.11.x"** button +2. Click the big yellow **"Download Python 3.12.x"** button 3. Run the installer 4. **IMPORTANT:** On the very first screen of the installer, tick the checkbox that says **"Add Python to PATH"** before clicking Install Now diff --git a/create_audiobook.py b/create_audiobook.py new file mode 100644 index 0000000..875f72e --- /dev/null +++ b/create_audiobook.py @@ -0,0 +1,402 @@ +""" +create_audiobook.py +------------------ +Generic audiobook generator for text files that contain chapter headings. + +Supported heading formats (single-line headings): +- Prologue +- Chapter 12 +- Chapter 12 - Chapter Name +- Chapter - 12 +- Chapter - 12 - Chapter Name + +Features: +- Parses chapters from one or more input files/directories +- Caches parsed chapter data for faster re-runs when source files are unchanged +- Warns about missing chapter numbers (example: found 1,2,4 -> warns about 3) +- Generates one .wav per chapter with Kokoro + +Examples: + python create_audiobook.py --input "Audio Text for Novel Lightbringer" + python create_audiobook.py --input novel.txt --list + python create_audiobook.py --input novel.txt 0 1 2 --voice am_michael + python create_audiobook.py --input novel.txt --preview 3000 +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import time +from pathlib import Path + +import numpy as np +import soundfile as sf +import torch +from kokoro import KPipeline + +SAMPLE_RATE = 24000 +SPEED = 1.0 +LANG_CODE = "a" +VOICE = "am_onyx" +CACHE_VERSION = 1 + +PROLOGUE_RE = re.compile(r"^\s*Prologue\s*$", re.IGNORECASE) +CHAPTER_RE_1 = re.compile(r"^\s*Chapter\s*-\s*(\d+)(?:\s*-\s*(.+))?\s*$", re.IGNORECASE) +CHAPTER_RE_2 = re.compile(r"^\s*Chapter\s+(\d+)(?:\s*-\s*(.+))?\s*$", re.IGNORECASE) +RULE_RE = re.compile(r"^[_\-*\s]{3,}\s*$") + + +def _slug(text: str) -> str: + text = text.lower() + text = re.sub(r"[^a-z0-9]+", "_", text) + return text.strip("_") + + +def _clean_text(text: str) -> str: + text = RULE_RE.sub("", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +def _fmt_duration(seconds: float) -> str: + h, rem = divmod(int(seconds), 3600) + m, s = divmod(rem, 60) + if h > 0: + return f"{h}h {m:02d}m {s:02d}s" + if m > 0: + return f"{m}m {s:02d}s" + return f"{s}s" + + +def _chapter_heading(line: str) -> tuple[int, str, str] | None: + stripped = line.strip() + if PROLOGUE_RE.match(stripped): + return (0, "Prologue", "Prologue") + + m = CHAPTER_RE_1.match(stripped) + if not m: + m = CHAPTER_RE_2.match(stripped) + if not m: + return None + + num = int(m.group(1)) + title = (m.group(2) or "").strip() + label = f"Chapter {num}" + (f" - {title}" if title else "") + return (num, title, label) + + +def _resolve_txt_files(inputs: list[str]) -> list[Path]: + txt_files: list[Path] = [] + for raw in inputs: + path = Path(raw) + if path.is_file(): + if path.suffix.lower() == ".txt": + txt_files.append(path) + continue + if path.is_dir(): + txt_files.extend(sorted(path.glob("*.txt"))) + + deduped = sorted({p.resolve() for p in txt_files}) + return deduped + + +def _signature_for_files(files: list[Path]) -> list[dict]: + sig = [] + for p in files: + st = p.stat() + sig.append({ + "path": str(p), + "size": st.st_size, + "mtime_ns": st.st_mtime_ns, + }) + return sig + + +def _cache_path(output_dir: Path, files: list[Path]) -> Path: + cache_dir = output_dir / ".cache" + digest = hashlib.sha256("\n".join(str(p) for p in files).encode("utf-8")).hexdigest()[:12] + return cache_dir / f"parse_{digest}.json" + + +def _load_cached_chapters(cache_file: Path, file_sig: list[dict]) -> list[dict] | None: + if not cache_file.exists(): + return None + + try: + data = json.loads(cache_file.read_text(encoding="utf-8")) + except Exception: + return None + + if data.get("version") != CACHE_VERSION: + return None + if data.get("file_signature") != file_sig: + return None + + chapters = data.get("chapters") + if not isinstance(chapters, list): + return None + return chapters + + +def _save_cached_chapters(cache_file: Path, file_sig: list[dict], chapters: list[dict]) -> None: + cache_file.parent.mkdir(parents=True, exist_ok=True) + payload = { + "version": CACHE_VERSION, + "file_signature": file_sig, + "chapters": chapters, + } + cache_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + + +def _parse_chapters(files: list[Path]) -> tuple[list[dict], set[int]]: + chapters: list[dict] = [] + duplicates: set[int] = set() + seen: set[int] = set() + current: dict | None = None + + def flush_current() -> None: + if current is not None: + current["text"] = "".join(current.pop("lines")) + num = current["num"] + if num in seen: + duplicates.add(num) + return + seen.add(num) + chapters.append(current) + + for fpath in files: + with fpath.open("r", encoding="utf-8") as fh: + for line in fh: + info = _chapter_heading(line) + if info is not None: + flush_current() + num, title, label = info + num_str = f"{num:02d}" + if num == 0: + slug = "chapter_00_prologue" + elif title: + slug = f"chapter_{num_str}_{_slug(title)}" + else: + slug = f"chapter_{num_str}" + current = { + "num": num, + "title": title, + "label": label, + "slug": slug, + "lines": [line], + } + elif current is not None: + current["lines"].append(line) + + flush_current() + chapters.sort(key=lambda c: c["num"]) + return chapters, duplicates + + +def load_all_chapters_with_cache(inputs: list[str], output_dir: Path, force_reparse: bool = False) -> tuple[list[dict], bool, set[int], list[Path]]: + files = _resolve_txt_files(inputs) + if not files: + raise FileNotFoundError("No .txt files found in --input paths") + + file_sig = _signature_for_files(files) + cache_file = _cache_path(output_dir, files) + + if not force_reparse: + cached = _load_cached_chapters(cache_file, file_sig) + if cached is not None: + return cached, True, set(), files + + chapters, duplicates = _parse_chapters(files) + _save_cached_chapters(cache_file, file_sig, chapters) + return chapters, False, duplicates, files + + +def warn_missing_chapters(chapters: list[dict]) -> None: + nums = sorted(ch["num"] for ch in chapters if ch["num"] > 0) + if not nums: + return + missing = [n for n in range(nums[0], nums[-1] + 1) if n not in set(nums)] + if missing: + print(f"WARNING: missing chapter numbers detected: {missing}") + + +def generate_audio(pipeline: KPipeline, text: str, voice: str, output_path: Path) -> float: + t0 = time.monotonic() + chunks = [] + for _, _, chunk_audio in pipeline(text, voice=voice, speed=SPEED): + if hasattr(chunk_audio, "numpy"): + chunk_audio = chunk_audio.cpu().numpy() + chunk_audio = np.atleast_1d(chunk_audio.squeeze()) + if chunk_audio.size > 0: + chunks.append(chunk_audio) + + elapsed = time.monotonic() - t0 + if chunks: + audio = np.concatenate(chunks, axis=0) + sf.write(str(output_path), audio, SAMPLE_RATE) + duration = len(audio) / SAMPLE_RATE + print( + f" OK saved '{output_path.name}' " + f"({_fmt_duration(duration)} audio | {_fmt_duration(elapsed)} wall-clock)" + ) + else: + print(f" ERROR no audio produced for voice='{voice}'") + return elapsed + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate an audiobook from chapterized text files.") + parser.add_argument( + "chapters", + nargs="*", + type=int, + help="Chapter numbers to generate (0 = Prologue). Default: all.", + ) + parser.add_argument( + "--input", + nargs="+", + required=True, + help="One or more .txt files and/or directories containing .txt files.", + ) + parser.add_argument( + "--output", + default="output_audiobook", + help="Output directory for generated chapter audio.", + ) + parser.add_argument("--list", action="store_true", help="Print detected chapters and exit.") + parser.add_argument("--voice", default=VOICE, help=f"Kokoro voice to use (default: {VOICE}).") + parser.add_argument( + "--preview", + nargs="?", + const=3000, + type=int, + metavar="CHARS", + help="Generate short preview clips capped at CHARS (default: 3000).", + ) + parser.add_argument( + "--reparse", + action="store_true", + help="Ignore cache and re-parse chapters from source files.", + ) + args = parser.parse_args() + + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + + print("Loading chapters...") + chapters, used_cache, duplicates, files = load_all_chapters_with_cache( + args.input, output_dir, force_reparse=args.reparse + ) + + print(f"Input files: {len(files)}") + print(f"Parse cache: {'HIT' if used_cache else 'MISS'}") + + if duplicates: + print(f"WARNING: duplicate chapter numbers were found and ignored: {sorted(duplicates)}") + + if not chapters: + print("WARNING: no chapters found.") + print("Expected headings like: 'Prologue' or 'Chapter 12 - Name' or 'Chapter - 12'") + return + + warn_missing_chapters(chapters) + + if args.list: + print(f"\nDetected {len(chapters)} chapters:\n") + print(f" {'#':>4} {'Label':<45} {'Chars':>8} {'Output filename'}") + print(f" {'-' * 4} {'-' * 45} {'-' * 8} {'-' * 30}") + for ch in chapters: + chars = len(_clean_text(ch["text"])) + print(f" {ch['num']:>4} {ch['label']:<45} {chars:>8,} {ch['slug']}.wav") + return + + if args.chapters: + requested = set(args.chapters) + run_chapters = [ch for ch in chapters if ch["num"] in requested] + missing_req = sorted(requested - {ch["num"] for ch in run_chapters}) + if missing_req: + print(f"WARNING: requested chapter(s) not found: {missing_req}") + else: + run_chapters = chapters + + if not run_chapters: + print("No chapters selected. Use --list to see available chapters.") + return + + device = "cuda" if torch.cuda.is_available() else "cpu" + print(f"Device: {device}") + if device == "cuda": + print(f"GPU: {torch.cuda.get_device_name(0)}") + print(f"Voice: {args.voice}") + + chapter_chars = {ch["num"]: len(_clean_text(ch["text"])) for ch in run_chapters} + total_chars = sum(chapter_chars.values()) + + preview_note = f"PREVIEW MODE: capped at {args.preview:,} chars/chapter" if args.preview else "" + if preview_note: + print(preview_note) + + print("\nPlan:") + for ch in run_chapters: + print(f" {ch['num']:>3} {ch['label']} ({chapter_chars[ch['num']]:,} chars)") + print(f" TOTAL: {total_chars:,} chars\n") + + print("Initializing Kokoro pipeline...") + pipeline = KPipeline(lang_code=LANG_CODE) + + chars_per_sec: float | None = None + timing_rows: list[tuple[str, int, float]] = [] + + for ch in run_chapters: + text = _clean_text(ch["text"]) + if not text: + print(f"[{ch['label']}] WARNING empty text, skipping") + continue + + if args.preview and len(text) > args.preview: + cut = text.rfind(" ", 0, args.preview) + text = text[: cut if cut > 0 else args.preview] + + chars = len(text) + preview_tag = "_preview" if args.preview else "" + out_path = output_dir / f"{ch['slug']}{preview_tag}.wav" + + if chars_per_sec is not None: + eta = _fmt_duration(chars / chars_per_sec) + print(f"\n[{ch['label']}] -> {out_path.name} (est. {eta})") + else: + print(f"\n[{ch['label']}] -> {out_path.name} (calibration run)") + + elapsed = generate_audio(pipeline, text, args.voice, out_path) + timing_rows.append((ch["label"], chars, elapsed)) + + done_chars = sum(c for _, c, _ in timing_rows) + done_elapsed = sum(e for _, _, e in timing_rows) + if done_elapsed > 0: + chars_per_sec = done_chars / done_elapsed + remaining = total_chars - done_chars + eta_total = _fmt_duration(remaining / chars_per_sec) if remaining > 0 else "0s" + print(f" Speed: {chars_per_sec:.0f} chars/sec | Estimated remaining: {eta_total}") + + print("\nSummary:") + print(f" {'Chapter':<35} {'Chars':>7} {'Actual':>8} {'Est':>8}") + print(" " + "-" * 65) + for i, (label, chars, elapsed) in enumerate(timing_rows): + actual_str = _fmt_duration(elapsed) + prior_chars = sum(c for _, c, _ in timing_rows[:i]) + prior_elapsed = sum(e for _, _, e in timing_rows[:i]) + est_str = _fmt_duration(chars / (prior_chars / prior_elapsed)) if prior_elapsed > 0 else "(first)" + print(f" {label:<35} {chars:>7,} {actual_str:>8} {est_str:>8}") + + total_elapsed = sum(e for _, _, e in timing_rows) + total_done_chars = sum(c for _, c, _ in timing_rows) + print(" " + "-" * 65) + print(f" {'TOTAL':<35} {total_done_chars:>7,} {_fmt_duration(total_elapsed):>8}") + print("\nDone.") + + +if __name__ == "__main__": + main() diff --git a/gui_proper_noun_player.py b/gui_proper_noun_player.py index 8268d27..0743540 100644 --- a/gui_proper_noun_player.py +++ b/gui_proper_noun_player.py @@ -47,9 +47,9 @@ Run: import json import os import re +import sys import threading -import tkinter as tk -from tkinter import ttk, messagebox +import time from pathlib import Path from typing import NamedTuple @@ -59,6 +59,34 @@ os.environ.setdefault("HF_HUB_OFFLINE", "1") import sounddevice as sd import soundfile as sf +from PySide6.QtWidgets import * +from PySide6.QtCore import * +from PySide6.QtGui import * + +# ── Project management ────────────────────────────────────────────────────────── + +class Project(NamedTuple): + name: str + source_paths: list[Path] + +def _project_slug(name: str) -> str: + return re.sub(r"[^a-zA-Z0-9_-]", "_", name).strip("_")[:60].lower() + +def load_projects() -> list[Project]: + projects_file = Path("projects.json") + if projects_file.exists(): + data = json.loads(projects_file.read_text(encoding="utf-8")) + projects = [] + for item in data: + paths = [Path(p) for p in item["source_paths"]] + projects.append(Project(name=item["name"], source_paths=paths)) + return projects + return [] + +def save_projects(projects: list[Project]) -> None: + data = [{"name": p.name, "source_paths": [str(path) for path in p.source_paths]} for p in projects] + Path("projects.json").write_text(json.dumps(data, indent=2), encoding="utf-8") + VOICE = "am_michael" SAMPLE_RATE = 24000 @@ -76,37 +104,14 @@ def _book_slug(text: str) -> str: return re.sub(r"[^a-zA-Z0-9_-]", "_", text).strip("_")[:60].lower() -def discover_books(root: Path = Path(".")) -> list[BookSource]: - """Scan the workspace root for candidate source text files and directories.""" - books: list[BookSource] = [] - EXCLUDE = {"tts fixed", "proper_noun", "table of contents", "columns pdf"} - - # Root-level .txt files (single-file books) - for f in sorted(root.glob("*.txt")): - name_lower = f.stem.lower() - if any(kw in name_lower for kw in EXCLUDE): - continue - if f.stat().st_size < 10_000: # skip tiny/metadata files - continue - slug = _book_slug(f.stem) - fixed_out = f.parent / f"{f.stem} (TTS Fixed).txt" - books.append(BookSource(label=f.stem, slug=slug, - source_paths=[f], fixed_out=fixed_out)) - - # Sub-directories containing .txt files (multi-file books, e.g. Lightbringer) - for d in sorted(root.iterdir()): - if not d.is_dir(): - continue - if d.name.startswith(("output_", "proper_noun", "__", ".")): - continue - txts = sorted(d.glob("*.txt")) - if not txts: - continue - slug = _book_slug(d.name) - fixed_out = d / f"{d.name} (TTS Fixed).txt" - books.append(BookSource(label=d.name, slug=slug, - source_paths=list(txts), fixed_out=fixed_out)) - +def load_books_from_projects() -> list[BookSource]: + projects = load_projects() + books = [] + for project in projects: + slug = _project_slug(project.name) + fixed_out = Path(f"{project.name} (TTS Fixed).txt") + books.append(BookSource(label=project.name, slug=slug, + source_paths=project.source_paths, fixed_out=fixed_out)) return books # ── Colours ──────────────────────────────────────────────────────────────────── @@ -139,6 +144,106 @@ def _slug(text: str) -> str: return re.sub(r"[^a-zA-Z0-9_-]", "_", text).strip("_")[:80] +_CHAPTER_LINE_RE = re.compile(r"^Chapter\s+(\d+)\s*-\s*(.+)\s*$", re.IGNORECASE) +_PROLOGUE_LINE_RE = re.compile(r"^Prologue\s*$", re.IGNORECASE) + + +def _chapter_slug(title: str) -> str: + text = title.lower() + text = re.sub(r"[^a-z0-9]+", "_", text) + return text.strip("_") + + +def _clean_tts_text(text: str) -> str: + text = re.sub(r"^[_\-\*\s]{3,}\s*$", "", text, flags=re.MULTILINE) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +def _parse_chapters_from_paths(source_paths: list[Path]) -> list[dict]: + """Parse chapters from source files. + + Supported heading formats: + - Prologue + - Chapter # - chapter name + """ + chapters: list[dict] = [] + current: dict | None = None + + for path in source_paths: + lines = path.read_text(encoding="utf-8").splitlines() + for line in lines: + m = _CHAPTER_LINE_RE.match(line.strip()) + if m: + if current is not None: + current["text"] = "\n".join(current["lines"]) + chapters.append(current) + num = int(m.group(1)) + title = m.group(2).strip() + current = { + "num": num, + "title": title, + "label": f"Chapter {num} - {title}", + "slug": f"chapter_{num:02d}_{_chapter_slug(title)}", + "lines": [line], + } + elif _PROLOGUE_LINE_RE.match(line.strip()): + if current is not None: + current["text"] = "\n".join(current["lines"]) + chapters.append(current) + current = { + "num": 0, + "title": "Prologue", + "label": "Prologue", + "slug": "chapter_00_prologue", + "lines": [line], + } + elif current is not None: + current["lines"].append(line) + + if current is not None: + current["text"] = "\n".join(current["lines"]) + chapters.append(current) + + deduped: list[dict] = [] + seen: set[int] = set() + for ch in chapters: + if ch["num"] in seen: + continue + seen.add(ch["num"]) + ch.pop("lines", None) + deduped.append(ch) + return sorted(deduped, key=lambda x: x["num"]) + + +def _parse_chapter_selection(raw: str, valid_numbers: set[int]) -> list[int]: + """Parse chapter selection like: all | 1,2,5-8.""" + text = (raw or "").strip().lower() + if not text or text == "all": + return sorted(valid_numbers) + + out: set[int] = set() + for part in text.split(","): + token = part.strip() + if not token: + continue + if "-" in token: + a, b = token.split("-", 1) + start = int(a.strip()) + end = int(b.strip()) + if end < start: + start, end = end, start + for n in range(start, end + 1): + if n in valid_numbers: + out.add(n) + else: + n = int(token) + if n in valid_numbers: + out.add(n) + + return sorted(out) + + # Lazy KPipeline singleton — only imported+loaded on first synthesis request _pipeline = None _pipeline_lock = threading.Lock() @@ -209,53 +314,20 @@ def save_json(path: Path, obj) -> None: path.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") -# ── Styled widget helpers ────────────────────────────────────────────────────── - -def make_listbox(parent) -> tuple[tk.Listbox, tk.Frame]: - frame = tk.Frame(parent, bg=BG2, bd=0) - sb = ttk.Scrollbar(frame, orient="vertical") - sb.pack(side="right", fill="y") - lb = tk.Listbox( - frame, - yscrollcommand=sb.set, - font=("Helvetica", 11), - bg=BG2, fg=FG, - selectbackground=BLUE, selectforeground=BG, - activestyle="none", bd=0, highlightthickness=0, relief="flat", - exportselection=False, - ) - lb.pack(side="left", fill="both", expand=True) - sb.config(command=lb.yview) - return lb, frame - -def styled_btn(parent, text, command, color=FG, bg=BG3, **kw): - return tk.Button( - parent, text=text, command=command, - bg=bg, fg=color, activebackground=BG2, activeforeground=color, - font=("Helvetica", 10, "bold"), relief="flat", bd=0, - padx=10, pady=5, cursor="hand2", **kw - ) - -def section_label(parent, text): - return tk.Label(parent, text=text, bg=BG, fg=FG_DIM, - font=("Helvetica", 9, "bold"), anchor="w") - - # ── Main app ─────────────────────────────────────────────────────────────────── -class ProperNounAuditor(tk.Tk): +class ProperNounAuditor(QMainWindow): # tracks which word is currently loaded into the fix entry _fix_entry_word: str = "" def __init__(self, books: list[BookSource]) -> None: super().__init__() - self.title("Proper Noun Pronunciation Auditor") - self.geometry("1020x760") - self.minsize(800, 560) - self.configure(bg=BG) + self.setWindowTitle("Proper Noun Pronunciation Auditor") + self.setGeometry(100, 100, 1020, 760) self.books: list[BookSource] = books + self.projects: list[Project] = load_projects() self.book: BookSource | None = None # Loaded per-book via _load_book() @@ -265,25 +337,32 @@ class ProperNounAuditor(tk.Tk): self.fixes: dict[str, str] = {} self._build_ui() - self._alive = True - self.protocol("WM_DELETE_WINDOW", self._on_close) - # Window-level hotkeys - self.bind("", lambda e: self._replay()) - self.bind("s", lambda e: sd.stop()) - self.bind("r", lambda e: self._regen_current() - if self.focus_get() is not self._fix_entry else None) - self.bind("", lambda e: self._reset_fix_entry()) + # Auto-load first project that has data; otherwise select first + if self.projects: + first_project = self.projects[0] + self._book_var.setCurrentText(first_project.name) + self._on_project_change() - # Auto-load first book that already has a manifest; otherwise select first - for book in books: - if (Path("output_proper_nouns") / book.slug / "manifest.json").exists(): - self._load_book(book) - break - else: - if books: - self._book_var.set(books[0].label) - self._on_book_change() + # Hotkeys + self._setup_shortcuts() + + def _setup_shortcuts(self): + # Space – replay + shortcut = QShortcut(QKeySequence("Space"), self) + shortcut.activated.connect(self._replay) + + # s – stop + shortcut = QShortcut(QKeySequence("S"), self) + shortcut.activated.connect(lambda: sd.stop()) + + # r – regen + shortcut = QShortcut(QKeySequence("R"), self) + shortcut.activated.connect(self._regen_current) + + # Escape – reset + shortcut = QShortcut(QKeySequence("Escape"), self) + shortcut.activated.connect(self._reset_fix_entry) # ── Per-book path properties ───────────────────────────────────────────────── @@ -317,7 +396,7 @@ class ProperNounAuditor(tk.Tk): """Switch to *book* — reload all state from its per-book data files.""" sd.stop() self.book = book - self._book_var.set(book.label) + self._book_var.setCurrentText(book.label) if self._manifest_file.exists(): self.manifest = load_json(self._manifest_file, {}) @@ -332,13 +411,14 @@ class ProperNounAuditor(tk.Tk): if n: status = f"{n} words loaded · {len(self.correct)} correct · {len(self.fixes)} fixes" else: - status = "No manifest yet — click ⚙ Extract & Generate Audio to create one" - self._book_status_var.set(status) + status = "No manifest yet — click find proper nouns to create one" + self._book_status_var.setText(status) self._refresh_all() - self.fix_var.set("") + self.fix_var = "" + self._fix_entry.setText("") self._fix_entry_word = "" - self.now_playing_var.set("—") + self.now_playing_var.setText("—") def _on_book_change(self, event=None) -> None: label = self._book_var.get() @@ -346,202 +426,324 @@ class ProperNounAuditor(tk.Tk): if book: self._load_book(book) - def _on_close(self) -> None: - self._alive = False - sd.stop() - self.destroy() + def _on_project_change(self) -> None: + name = self._book_var.currentText() + project = next((p for p in self.projects if p.name == name), None) + if project: + # Create BookSource from project + slug = _project_slug(project.name) + fixed_out = Path(f"{project.name} (TTS Fixed).txt") + book = BookSource(label=project.name, slug=slug, + source_paths=project.source_paths, fixed_out=fixed_out) + self.books = [book] + self._load_book(book) - def _safe_after(self, ms: int, func) -> None: - """Schedule func on the Tk thread; silently no-ops if window is gone.""" - if self._alive: - try: - self.after(ms, func) - except RuntimeError: - pass + def _new_project(self) -> None: + name, ok = QInputDialog.getText(self, "New Project", "Enter project name:") + if ok and name: + # Check if exists + if any(p.name == name for p in self.projects): + QMessageBox.critical(self, "Error", "Project name already exists.") + return + # Select files + files, _ = QFileDialog.getOpenFileNames(self, "Select TXT files", "", "Text files (*.txt)") + if files: + paths = [Path(f) for f in files] + project = Project(name=name, source_paths=paths) + self.projects.append(project) + save_projects(self.projects) + # Update combobox values + self._book_var.clear() + self._book_var.addItems([p.name for p in self.projects]) + self._book_var.setCurrentText(name) + self._on_project_change() + + def _add_files(self) -> None: + if not self._book_var.currentText(): + QMessageBox.information(self, "No project selected", "Select a project first.") + return + files, _ = QFileDialog.getOpenFileNames(self, "Add TXT files", "", "Text files (*.txt)") + if files: + name = self._book_var.currentText() + project = next((p for p in self.projects if p.name == name), None) + if project: + new_paths = [Path(f) for f in files if Path(f) not in project.source_paths] + project.source_paths.extend(new_paths) + save_projects(self.projects) + self._on_project_change() + + def closeEvent(self, event) -> None: + sd.stop() + event.accept() # ── UI construction ──────────────────────────────────────────────────────── def _build_ui(self) -> None: - PAD = 8 + central_widget = QWidget() + self.setCentralWidget(central_widget) + main_layout = QVBoxLayout(central_widget) - # ── Book selector bar ────────────────────────────────────────────────────── - book_bar = tk.Frame(self, bg=BG2, pady=7) - book_bar.pack(fill="x") + # ── Project selector bar ────────────────────────────────────────────────── + book_bar = QWidget() + book_bar.setStyleSheet(f"background-color: {BG2}; padding: 7px;") + book_layout = QHBoxLayout(book_bar) - tk.Label(book_bar, text=" Book:", bg=BG2, fg=FG_DIM, - font=("Helvetica", 10, "bold")).pack(side="left", padx=(10, 4)) + book_label = QLabel("Project:") + book_label.setStyleSheet(f"color: {FG_DIM}; font-weight: bold; font-size: 10pt;") + book_layout.addWidget(book_label) - self._book_var = tk.StringVar() - book_menu = ttk.Combobox( - book_bar, textvariable=self._book_var, - values=[b.label for b in self.books], - state="readonly", font=("Helvetica", 10), width=44) - book_menu.pack(side="left", padx=(0, 8)) - book_menu.bind("<>", self._on_book_change) + self._book_var = QComboBox() + self._book_var.addItems([p.name for p in self.projects]) + self._book_var.setEditable(False) + self._book_var.setStyleSheet(f"font-size: 10pt; min-width: 300px;") + self._book_var.currentTextChanged.connect(self._on_project_change) + book_layout.addWidget(self._book_var) - self._extract_btn = styled_btn( - book_bar, "⚙ Extract & Generate Audio", - self._extract_and_generate, color=GREEN, bg=BG3) - self._extract_btn.pack(side="left", padx=4) + new_project_btn = self._create_button("New Project", self._new_project, BLUE, BG3) + book_layout.addWidget(new_project_btn) - styled_btn(book_bar, "⇄ Apply Fixes to Text", - self._apply_fixes, color=YELLOW, bg=BG3).pack(side="left", padx=4) - styled_btn(book_bar, "⬇ Export Remaining", - self._export_remaining, color=BLUE, bg=BG3).pack(side="left", padx=4) + add_files_btn = self._create_button("Add Files", self._add_files, GREEN, BG3) + book_layout.addWidget(add_files_btn) - self._book_status_var = tk.StringVar(value="Select a book above") - tk.Label(book_bar, textvariable=self._book_status_var, - bg=BG2, fg=FG_DIM, font=("Helvetica", 9), - anchor="w").pack(side="left", padx=(10, 10)) + self._extract_btn = self._create_button("find proper nouns", self._extract_and_generate, GREEN, BG3) + book_layout.addWidget(self._extract_btn) + + apply_fixes_btn = self._create_button("⇄ Apply Fixes to Text", self._apply_fixes, YELLOW, BG3) + book_layout.addWidget(apply_fixes_btn) + + export_remaining_btn = self._create_button("⬇ Export Remaining", self._export_remaining, BLUE, BG3) + book_layout.addWidget(export_remaining_btn) + + voice_label = QLabel("Voice:") + voice_label.setStyleSheet(f"color: {FG_DIM}; font-size: 9pt;") + book_layout.addWidget(voice_label) + + self._voice_combo = QComboBox() + self._voice_combo.setEditable(True) + self._voice_combo.addItems([ + "am_onyx", + "am_michael", + "af_heart", + "af_bella", + "af_nicole", + "bm_george", + "bm_lewis", + ]) + self._voice_combo.setCurrentText("am_onyx") + self._voice_combo.setStyleSheet("font-size: 9pt; min-width: 120px;") + book_layout.addWidget(self._voice_combo) + + chapters_label = QLabel("Chapters:") + chapters_label.setStyleSheet(f"color: {FG_DIM}; font-size: 9pt;") + book_layout.addWidget(chapters_label) + + self._chapters_entry = QLineEdit("all") + self._chapters_entry.setPlaceholderText("all or 0,1,2,5-8") + self._chapters_entry.setStyleSheet("font-size: 9pt; min-width: 130px;") + book_layout.addWidget(self._chapters_entry) + + self._gen_audio_btn = self._create_button("Generate Audio", self._generate_selected_chapters, MAUVE, BG3) + book_layout.addWidget(self._gen_audio_btn) + + self._gen_audio_status = QLabel("") + self._gen_audio_status.setStyleSheet(f"color: {FG_DIM}; font-size: 8pt;") + book_layout.addWidget(self._gen_audio_status) + + self._book_status_var = QLabel("Select a book above") + self._book_status_var.setStyleSheet(f"color: {FG_DIM}; font-size: 9pt;") + book_layout.addWidget(self._book_status_var) + book_layout.addStretch() + + main_layout.addWidget(book_bar) # ── Title bar ───────────────────────────────────────────────────────── - title_bar = tk.Frame(self, bg=BG, pady=6) - title_bar.pack(fill="x", padx=PAD) - tk.Label(title_bar, text="Proper Noun Pronunciation Auditor", - font=("Helvetica", 15, "bold"), bg=BG, fg=FG).pack(side="left") - hint = "Space=replay r=regen s=stop Esc=reset fix Del=remove from list Enter=correct|fix" - tk.Label(title_bar, text=hint, - font=("Helvetica", 8), bg=BG, fg=FG_DIM).pack(side="left", padx=14) + title_bar = QWidget() + title_bar.setStyleSheet(f"background-color: {BG}; padding: 6px;") + title_layout = QHBoxLayout(title_bar) + + title_label = QLabel("Proper Noun Pronunciation Auditor") + title_label.setStyleSheet(f"font-size: 15pt; font-weight: bold; color: {FG};") + title_layout.addWidget(title_label) + + hint_label = QLabel("Space=replay r=regen s=stop Esc=reset fix Del=remove from list Enter=correct|fix") + hint_label.setStyleSheet(f"font-size: 8pt; color: {FG_DIM};") + title_layout.addWidget(hint_label) + title_layout.addStretch() + + main_layout.addWidget(title_bar) # Three-column body - body = tk.Frame(self, bg=BG) - body.pack(fill="both", expand=True, padx=PAD, pady=(0, PAD)) - body.columnconfigure(0, weight=3) - body.columnconfigure(1, weight=2) - body.columnconfigure(2, weight=2) - body.rowconfigure(0, weight=1) + body = QWidget() + body_layout = QHBoxLayout(body) + body_layout.setSpacing(8) # ── Column 0: Review list ────────────────────────────────────────────── - col0 = tk.Frame(body, bg=BG) - col0.grid(row=0, column=0, sticky="nsew", padx=(0, PAD)) + col0 = QWidget() + col0_layout = QVBoxLayout(col0) - filter_row = tk.Frame(col0, bg=BG) - filter_row.pack(fill="x", pady=(0, 4)) - tk.Label(filter_row, text="Filter:", bg=BG, fg=FG, - font=("Helvetica", 10)).pack(side="left", padx=(0, 4)) - self.search_var = tk.StringVar() - self.search_var.trace_add("write", lambda *_: self._refresh_review()) - self._filter_entry = tk.Entry( - filter_row, textvariable=self.search_var, - font=("Helvetica", 11), bg=BG3, fg=FG, - insertbackground=FG, relief="flat", bd=4) - self._filter_entry.pack(side="left", fill="x", expand=True) - self._filter_entry.focus_set() - styled_btn(filter_row, "✕", lambda: self.search_var.set(""), - color=RED, bg=BG3).pack(side="left", padx=(3, 0)) + filter_row = QWidget() + filter_layout = QHBoxLayout(filter_row) + filter_label = QLabel("Filter:") + filter_label.setStyleSheet(f"color: {FG}; font-size: 10pt;") + filter_layout.addWidget(filter_label) - hdr0 = tk.Frame(col0, bg=BG) - hdr0.pack(fill="x") - section_label(hdr0, "TO REVIEW").pack(side="left") - self.review_count_var = tk.StringVar() - tk.Label(hdr0, textvariable=self.review_count_var, bg=BG, fg=FG_DIM, - font=("Helvetica", 9)).pack(side="right") + self.search_var = "" + self._filter_entry = QLineEdit() + self._filter_entry.setStyleSheet(f"font-size: 11pt; background-color: {BG3}; color: {FG}; border: 1px solid {BG3}; padding: 4px;") + self._filter_entry.textChanged.connect(self._refresh_review) + filter_layout.addWidget(self._filter_entry) - self.review_lb, review_frame = make_listbox(col0) - review_frame.pack(fill="both", expand=True) - self.review_lb.bind("<>", self._on_review_select) - self.review_lb.bind("", self._on_review_select) + clear_filter_btn = self._create_button("✕", lambda: self._filter_entry.clear(), RED, BG3) + filter_layout.addWidget(clear_filter_btn) + + col0_layout.addWidget(filter_row) + + hdr0 = QWidget() + hdr0_layout = QHBoxLayout(hdr0) + review_section_label = QLabel("TO REVIEW") + review_section_label.setStyleSheet(f"font-weight: bold; color: {FG};") + hdr0_layout.addWidget(review_section_label) + + self.review_count_var = QLabel("") + self.review_count_var.setStyleSheet(f"color: {FG_DIM}; font-size: 9pt;") + hdr0_layout.addStretch() + hdr0_layout.addWidget(self.review_count_var) + + col0_layout.addWidget(hdr0) + + self.review_lb = QListWidget() + self.review_lb.setStyleSheet(f"background-color: {BG2}; color: {FG}; border: none;") + self.review_lb.itemSelectionChanged.connect(self._on_review_select) + self.review_lb.itemDoubleClicked.connect(self._on_review_select) + self.review_lb.keyPressEvent = self._review_key_press + col0_layout.addWidget(self.review_lb) + + body_layout.addWidget(col0, 3) # ── Column 1: Correct list ───────────────────────────────────────────── - col1 = tk.Frame(body, bg=BG) - col1.grid(row=0, column=1, sticky="nsew", padx=(0, PAD)) + col1 = QWidget() + col1_layout = QVBoxLayout(col1) - hdr1 = tk.Frame(col1, bg=BG) - hdr1.pack(fill="x") - section_label(hdr1, "✓ CORRECT [Del=remove]").pack(side="left") - self.correct_count_var = tk.StringVar() - tk.Label(hdr1, textvariable=self.correct_count_var, bg=BG, fg=FG_DIM, - font=("Helvetica", 9)).pack(side="right") + hdr1 = QWidget() + hdr1_layout = QHBoxLayout(hdr1) + correct_section_label = QLabel("✓ CORRECT [Del=remove]") + correct_section_label.setStyleSheet(f"font-weight: bold; color: {FG};") + hdr1_layout.addWidget(correct_section_label) - self.correct_lb, correct_frame = make_listbox(col1) - correct_frame.pack(fill="both", expand=True) - self.correct_lb.bind("<>", - lambda e: self._on_side_select(self.correct_lb)) - self.correct_lb.bind("", - lambda e: self._move_back(self.correct_lb, is_dict=False)) - self.correct_lb.bind("", - lambda e: self._move_back(self.correct_lb, is_dict=False)) + self.correct_count_var = QLabel("") + self.correct_count_var.setStyleSheet(f"color: {FG_DIM}; font-size: 9pt;") + hdr1_layout.addStretch() + hdr1_layout.addWidget(self.correct_count_var) - styled_btn(col1, "← Back to Review [Del]", - lambda: self._move_back(self.correct_lb, is_dict=False), - color=YELLOW).pack(fill="x", pady=(4, 0)) + col1_layout.addWidget(hdr1) + + self.correct_lb = QListWidget() + self.correct_lb.setStyleSheet(f"background-color: {BG2}; color: {FG}; border: none;") + self.correct_lb.itemSelectionChanged.connect(lambda: self._on_side_select(self.correct_lb)) + self.correct_lb.keyPressEvent = lambda e: self._side_key_press(e, self.correct_lb, False) + col1_layout.addWidget(self.correct_lb) + + back_to_review_btn = self._create_button("← Back to Review [Del]", lambda: self._move_back(self.correct_lb, is_dict=False), YELLOW) + col1_layout.addWidget(back_to_review_btn) + + body_layout.addWidget(col1, 2) # ── Column 2: Fixes list ─────────────────────────────────────────────── - col2 = tk.Frame(body, bg=BG) - col2.grid(row=0, column=2, sticky="nsew") + col2 = QWidget() + col2_layout = QVBoxLayout(col2) - hdr2 = tk.Frame(col2, bg=BG) - hdr2.pack(fill="x") - section_label(hdr2, "⇄ FIXES (original → phonetic)").pack(side="left") - self.fixes_count_var = tk.StringVar() - tk.Label(hdr2, textvariable=self.fixes_count_var, bg=BG, fg=FG_DIM, - font=("Helvetica", 9)).pack(side="right") + hdr2 = QWidget() + hdr2_layout = QHBoxLayout(hdr2) + fixes_section_label = QLabel("⇄ FIXES (original → phonetic)") + fixes_section_label.setStyleSheet(f"font-weight: bold; color: {FG};") + hdr2_layout.addWidget(fixes_section_label) - self.fixes_lb, fixes_frame = make_listbox(col2) - fixes_frame.pack(fill="both", expand=True) - self.fixes_lb.bind("<>", - lambda e: self._on_side_select(self.fixes_lb)) - self.fixes_lb.bind("", - lambda e: self._move_back(self.fixes_lb, is_dict=True)) - self.fixes_lb.bind("", - lambda e: self._move_back(self.fixes_lb, is_dict=True)) + self.fixes_count_var = QLabel("") + self.fixes_count_var.setStyleSheet(f"color: {FG_DIM}; font-size: 9pt;") + hdr2_layout.addStretch() + hdr2_layout.addWidget(self.fixes_count_var) - styled_btn(col2, "← Back to Review [Del]", - lambda: self._move_back(self.fixes_lb, is_dict=True), - color=YELLOW).pack(fill="x", pady=(4, 0)) + col2_layout.addWidget(hdr2) + + self.fixes_lb = QListWidget() + self.fixes_lb.setStyleSheet(f"background-color: {BG2}; color: {FG}; border: none;") + self.fixes_lb.itemSelectionChanged.connect(lambda: self._on_side_select(self.fixes_lb)) + self.fixes_lb.keyPressEvent = lambda e: self._side_key_press(e, self.fixes_lb, True) + col2_layout.addWidget(self.fixes_lb) + + back_to_review_fixes_btn = self._create_button("← Back to Review [Del]", lambda: self._move_back(self.fixes_lb, is_dict=True), YELLOW) + col2_layout.addWidget(back_to_review_fixes_btn) + + body_layout.addWidget(col2, 2) + + main_layout.addWidget(body) # ── Bottom action bar ────────────────────────────────────────────────── - action_bar = tk.Frame(self, bg=BG3, pady=8) - action_bar.pack(fill="x") + action_bar = QWidget() + action_bar.setStyleSheet(f"background-color: {BG3}; padding: 8px;") + action_layout = QHBoxLayout(action_bar) - # Now-playing word label - tk.Label(action_bar, text="▶", bg=BG3, fg=GREEN, - font=("Helvetica", 11)).pack(side="left", padx=(10, 2)) - self.now_playing_var = tk.StringVar(value="—") - tk.Label(action_bar, textvariable=self.now_playing_var, - bg=BG3, fg=GREEN, font=("Helvetica", 11, "bold"), - width=20, anchor="w").pack(side="left") + playing_icon = QLabel("▶") + playing_icon.setStyleSheet(f"color: {GREEN}; font-size: 11pt;") + action_layout.addWidget(playing_icon) - # Inline fix entry — right next to the word, auto-focused on word click - tk.Label(action_bar, text="→", bg=BG3, fg=MAUVE, - font=("Helvetica", 13, "bold")).pack(side="left", padx=(6, 3)) - self.fix_var = tk.StringVar() - self._fix_entry = tk.Entry( - action_bar, textvariable=self.fix_var, - font=("Helvetica", 11), bg=BG2, fg=MAUVE, - insertbackground=MAUVE, relief="flat", bd=4, width=22) - self._fix_entry.pack(side="left") - self._fix_entry.bind("", lambda e: self._enter_action()) - self._fix_entry.bind("", lambda e: self._reset_fix_entry()) - self._fix_entry.bind("", lambda e: (self._navigate_review(-1), "break")[1]) - self._fix_entry.bind("", lambda e: (self._navigate_review(+1), "break")[1]) + self.now_playing_var = QLabel("—") + self.now_playing_var.setStyleSheet(f"color: {GREEN}; font-size: 11pt; font-weight: bold; min-width: 150px;") + action_layout.addWidget(self.now_playing_var) - tk.Label(action_bar, text="Enter=correct (edit first for fix) Esc=reset", - bg=BG3, fg=FG_DIM, font=("Helvetica", 8)).pack(side="left", padx=(5, 10)) + arrow_label = QLabel("→") + arrow_label.setStyleSheet(f"color: {MAUVE}; font-size: 13pt; font-weight: bold;") + action_layout.addWidget(arrow_label) - tk.Label(action_bar, text="│", bg=BG3, fg=FG_DIM).pack(side="left", padx=4) - styled_btn(action_bar, "■ Stop [s]", sd.stop, - color=RED).pack(side="left", padx=4) - styled_btn(action_bar, "↺ Replay [Space]", self._replay, - color=BLUE).pack(side="left", padx=2) - styled_btn(action_bar, "↻ Regen [r]", self._regen_current, - color=GREEN).pack(side="left", padx=2) + self.fix_var = "" + self._fix_entry = QLineEdit() + self._fix_entry.setStyleSheet(f"font-size: 11pt; background-color: {BG2}; color: {MAUVE}; border: 1px solid {BG2}; padding: 4px; max-width: 150px;") + self._fix_entry.returnPressed.connect(self._enter_action) + self._fix_entry.keyPressEvent = self._fix_entry_key_press + action_layout.addWidget(self._fix_entry) - tk.Label(action_bar, text="│", bg=BG3, fg=FG_DIM).pack(side="left", padx=4) - self._pregen_btn = styled_btn( - action_bar, "↻ Pre-gen Fix Audio", - self._pregen_all_fix_audio, color=MAUVE, bg=BG2) - self._pregen_btn.pack(side="left", padx=4) - self._pregen_status_var = tk.StringVar(value="") - tk.Label(action_bar, textvariable=self._pregen_status_var, - bg=BG3, fg=FG_DIM, font=("Helvetica", 8), - width=28, anchor="w").pack(side="left", padx=(4, 10)) + hint_action = QLabel("Enter=correct (edit first for fix) Esc=reset") + hint_action.setStyleSheet(f"color: {FG_DIM}; font-size: 8pt;") + action_layout.addWidget(hint_action) + + separator1 = QLabel("│") + separator1.setStyleSheet(f"color: {FG_DIM};") + action_layout.addWidget(separator1) + + stop_btn = self._create_button("■ Stop [s]", lambda: sd.stop(), RED) + action_layout.addWidget(stop_btn) + + replay_btn = self._create_button("↺ Replay [Space]", self._replay, BLUE) + action_layout.addWidget(replay_btn) + + regen_btn = self._create_button("↻ Regen [r]", self._regen_current, GREEN) + action_layout.addWidget(regen_btn) + + separator2 = QLabel("│") + separator2.setStyleSheet(f"color: {FG_DIM};") + action_layout.addWidget(separator2) + + self._pregen_btn = self._create_button("↻ Pre-gen Fix Audio", self._pregen_all_fix_audio, MAUVE, BG2) + action_layout.addWidget(self._pregen_btn) + + self._pregen_status_var = QLabel("") + self._pregen_status_var.setStyleSheet(f"color: {FG_DIM}; font-size: 8pt; min-width: 200px;") + action_layout.addWidget(self._pregen_status_var) + action_layout.addStretch() + + main_layout.addWidget(action_bar) + + def _create_button(self, text: str, callback, color: str = GREEN, bg: str = BG3) -> QPushButton: + btn = QPushButton(text) + btn.setStyleSheet(f"background-color: {bg}; color: {color}; border: 1px solid {color}; padding: 4px 8px;") + btn.clicked.connect(callback) + return btn # ── Refresh helpers ──────────────────────────────────────────────────────── def _review_words(self) -> list[str]: excluded = set(self.correct) | set(self.fixes.keys()) - q = self.search_var.get().strip().casefold() + q = self._filter_entry.text().strip().casefold() words = [w for w in self.all_words if w not in excluded] if q: words = [w for w in words if q in w.casefold()] @@ -549,22 +751,22 @@ class ProperNounAuditor(tk.Tk): def _refresh_review(self) -> None: words = self._review_words() - self.review_lb.delete(0, "end") + self.review_lb.clear() for w in words: - self.review_lb.insert("end", f" {w}") - self.review_count_var.set(f"{len(words)}") + self.review_lb.addItem(f" {w}") + self.review_count_var.setText(f"{len(words)}") def _refresh_correct(self) -> None: - self.correct_lb.delete(0, "end") + self.correct_lb.clear() for w in self.correct: # already newest-first - self.correct_lb.insert("end", f" {w}") - self.correct_count_var.set(f"{len(self.correct)}") + self.correct_lb.addItem(f" {w}") + self.correct_count_var.setText(f"{len(self.correct)}") def _refresh_fixes(self) -> None: - self.fixes_lb.delete(0, "end") + self.fixes_lb.clear() for orig, rep in reversed(list(self.fixes.items())): # newest-first - self.fixes_lb.insert("end", f" {orig} → {rep}") - self.fixes_count_var.set(f"{len(self.fixes)}") + self.fixes_lb.addItem(f" {orig} → {rep}") + self.fixes_count_var.setText(f"{len(self.fixes)}") def _refresh_all(self) -> None: self._refresh_review() @@ -581,59 +783,79 @@ class ProperNounAuditor(tk.Tk): return wav_path = self._audio_dir / wav_name if not wav_path.exists(): - messagebox.showwarning("Missing audio", - f"No audio file for '{word}'.\n" - "Click '⚙ Extract & Generate Audio' first.") + QMessageBox.warning(self, "Missing audio", + f"No audio file for '{word}'.\n" + "Click 'find proper nouns' first.") return - self.now_playing_var.set(word) + self.now_playing_var.setText(word) play_async(wav_path) # ── Selection callbacks ──────────────────────────────────────────────────── - def _on_review_select(self, event=None) -> None: - sel = self.review_lb.curselection() - if not sel: + def _on_review_select(self) -> None: + item = self.review_lb.currentItem() + if not item: return - word = self.review_lb.get(sel[0]).strip() + word = item.text().strip() self._fix_entry_word = word - self.fix_var.set(word) # pre-fill fix entry with the word - self._fix_entry.selection_range(0, "end") - self._fix_entry.icursor("end") - # Defer focus so the listbox doesn't reclaim it after the click event settles - self.after(0, self._fix_entry.focus_set) + self.fix_var = word # pre-fill fix entry with the word + self._fix_entry.setText(word) + self._fix_entry.selectAll() + self._fix_entry.setFocus() self._play_word(word) - def _on_side_select(self, listbox: tk.Listbox) -> None: + def _on_side_select(self, listbox: QListWidget) -> None: if not self.book: return - sel = listbox.curselection() - if not sel: + item = listbox.currentItem() + if not item: return - row = listbox.get(sel[0]).strip() + row = item.text().strip() parts = row.split(" → ") original = parts[0].strip() if listbox is self.fixes_lb and len(parts) == 2: replacement = parts[1].strip() self._fix_entry_word = original - self.fix_var.set(replacement) - self.now_playing_var.set(f"… {replacement}") + self.fix_var = replacement + self._fix_entry.setText(replacement) + self.now_playing_var.setText(f"… {replacement}") rdir = self._replacements_dir def _on_ready(_path): - self._safe_after(0, lambda: self.now_playing_var.set(replacement)) + self.now_playing_var.setText(replacement) synth_and_play(replacement, rdir, on_ready=_on_ready) else: self._fix_entry_word = original - self.fix_var.set(original) + self.fix_var = original + self._fix_entry.setText(original) self._play_word(original) + def _review_key_press(self, event): + if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: + self._on_review_select() + else: + QListWidget.keyPressEvent(self.review_lb, event) + + def _fix_entry_key_press(self, event): + if event.key() == Qt.Key_Up: + self._navigate_review(-1) + event.accept() + elif event.key() == Qt.Key_Down: + self._navigate_review(1) + event.accept() + elif event.key() == Qt.Key_Escape: + self._reset_fix_entry() + event.accept() + else: + QLineEdit.keyPressEvent(self._fix_entry, event) + # ── Actions ──────────────────────────────────────────────────────────────── def _selected_review_word(self) -> str | None: - sel = self.review_lb.curselection() - if not sel: + item = self.review_lb.currentItem() + if not item: return None - return self.review_lb.get(sel[0]).strip() + return item.text().strip() def _enter_action(self) -> None: """Smart Enter handler for the fix entry. @@ -644,7 +866,7 @@ class ProperNounAuditor(tk.Tk): word = self._fix_entry_word or self._selected_review_word() if not word: return - text = self.fix_var.get().strip() + text = self.fix_var.strip() if not text or text == word: self._mark_correct_word(word) else: @@ -652,8 +874,9 @@ class ProperNounAuditor(tk.Tk): def _reset_fix_entry(self) -> None: """Escape: reset fix entry to the original word, refocus the review list.""" - self.fix_var.set(self._fix_entry_word) - self.review_lb.focus_set() + self.fix_var = self._fix_entry_word + self._fix_entry.setText(self._fix_entry_word) + self.review_lb.setFocus() def _replay(self) -> None: if self._fix_entry_word: @@ -666,7 +889,7 @@ class ProperNounAuditor(tk.Tk): return # Determine which file to delete based on context - fix_text = self.fix_var.get().strip() + fix_text = self.fix_var.strip() # If the fix box contains something different from the word, regen that text is_fix_replacement = bool(fix_text and fix_text != word) @@ -676,10 +899,10 @@ class ProperNounAuditor(tk.Tk): target = self._replacements_dir / f"{_slug(fix_text)}.wav" if target.exists(): target.unlink() - self.now_playing_var.set(f"… regen {fix_text}") + self.now_playing_var.setText(f"… regen {fix_text}") rdir = self._replacements_dir def _on_ready(_p): - self._safe_after(0, lambda: self.now_playing_var.set(fix_text)) + self.now_playing_var.setText(fix_text) synth_and_play(fix_text, rdir, on_ready=_on_ready) else: wav_name = self.manifest.get(word) @@ -688,7 +911,7 @@ class ProperNounAuditor(tk.Tk): wav_path = self._audio_dir / wav_name if wav_path.exists(): wav_path.unlink() - self.now_playing_var.set(f"… regen {word}") + self.now_playing_var.setText(f"… regen {word}") def _regen(): try: @@ -702,7 +925,7 @@ class ProperNounAuditor(tk.Tk): chunks.append(audio) if chunks: sf.write(str(wav_path), np.concatenate(chunks), SAMPLE_RATE) - self._safe_after(0, lambda: self.now_playing_var.set(word)) + self.now_playing_var.setText(word) play_async(wav_path) except Exception as exc: print(f"[regen] error for '{word}': {exc}") @@ -711,40 +934,28 @@ class ProperNounAuditor(tk.Tk): def _navigate_review(self, delta: int) -> None: """Move the review list selection up (delta=-1) or down (delta=+1).""" - size = self.review_lb.size() - if size == 0: + count = self.review_lb.count() + if count == 0: return - sel = self.review_lb.curselection() - current = sel[0] if sel else -1 - new_idx = max(0, min(size - 1, current + delta)) - if new_idx == current: + current_row = self.review_lb.currentRow() + if current_row == -1: + current_row = 0 + new_row = max(0, min(count - 1, current_row + delta)) + if new_row == current_row: return - self.review_lb.selection_clear(0, "end") - self.review_lb.selection_set(new_idx) - self.review_lb.see(new_idx) - self.review_lb.event_generate("<>") + self.review_lb.setCurrentRow(new_row) + self._on_review_select() def _advance_review(self, from_idx: int = 0) -> None: """Select the item at from_idx (clamped), positioned in the upper portion of the viewport so the word doesn't end up in the bottom half unless the list can't scroll any further down.""" - size = self.review_lb.size() - if size == 0: + count = self.review_lb.count() + if count == 0: return - target = min(from_idx, size - 1) - self.review_lb.selection_clear(0, "end") - self.review_lb.selection_set(target) - - # First call see() to let tk calculate the viewport, then reposition. - self.review_lb.see(target) - self.review_lb.update_idletasks() - - first, last = self.review_lb.yview() - visible_count = max(1, round((last - first) * size)) - - # Ideal top-of-viewport: put target ~1/4 down from the top - ideal_top = target - visible_count // 4 - ideal_top = max(0, ideal_top) + target = min(from_idx, count - 1) + self.review_lb.setCurrentRow(target) + self.review_lb.scrollToItem(self.review_lb.item(target)) self.review_lb.yview_moveto(ideal_top / size) self.review_lb.event_generate("<>") @@ -756,28 +967,30 @@ class ProperNounAuditor(tk.Tk): self.correct.insert(0, word) save_json(self._correct_file, self.correct) self._fix_entry_word = "" - self.fix_var.set("") - self.now_playing_var.set("—") + self.fix_var = "" + self._fix_entry.setText("") + self.now_playing_var.setText("—") self._refresh_all() self._advance_review(from_idx) def _add_fix_for_word(self, word: str, replacement: str) -> None: - idx = self.review_lb.curselection() - from_idx = idx[0] if idx else 0 + current_row = self.review_lb.currentRow() + from_idx = current_row if current_row != -1 else 0 self.fixes.pop(word, None) self.fixes[word] = replacement save_json(self._fixes_file, self.fixes) self._fix_entry_word = "" - self.fix_var.set("") - self.now_playing_var.set("—") + self.fix_var = "" + self._fix_entry.setText("") + self.now_playing_var.setText("—") self._refresh_all() self._advance_review(from_idx) - def _move_back(self, listbox: tk.Listbox, is_dict: bool) -> None: - sel = listbox.curselection() - if not sel: + def _move_back(self, listbox: QListWidget, is_dict: bool) -> None: + item = listbox.currentItem() + if not item: return - raw = listbox.get(sel[0]).strip().split(" → ")[0].strip() + raw = item.text().strip().split(" → ")[0].strip() if is_dict: self.fixes.pop(raw, None) save_json(self._fixes_file, self.fixes) @@ -797,28 +1010,28 @@ class ProperNounAuditor(tk.Tk): generate a TTS audio clip for each one. Runs in a background thread. """ if not self.book: - messagebox.showinfo("No book selected", "Please select a book first.") + QMessageBox.information(self, "No book selected", "Please select a book first.") return missing = [p for p in self.book.source_paths if not p.exists()] if missing: - messagebox.showerror( - "Source file(s) not found", + QMessageBox.critical( + self, "Source file(s) not found", "Could not find:\n" + "\n".join(str(p) for p in missing)) return - self._extract_btn.config(state="disabled") - self._book_status_var.set("Loading spaCy NLP model…") + self._extract_btn.setEnabled(False) + self._book_status_var.setText("Loading spaCy NLP model…") book = self.book # capture for the thread def _run(): try: - self._safe_after(0, lambda: self._book_status_var.set( - "Running NLP extraction (may take a minute)…")) + self._book_status_var.setText( + "Running NLP extraction (may take a minute)…") words = _extract_nouns_from_paths(book.source_paths) n_extracted = len(words) - self._safe_after(0, lambda: self._book_status_var.set( - f"Extracted {n_extracted} nouns — generating audio…")) + self._book_status_var.setText( + f"Extracted {n_extracted} nouns — generating audio…") data_dir = Path("output_proper_nouns") / book.slug audio_dir = Path("proper_nouns_audio") / book.slug @@ -859,33 +1072,30 @@ class ProperNounAuditor(tk.Tk): if i % 10 == 0: remaining = n_extracted - i - self._safe_after(0, lambda r=remaining: - self._book_status_var.set(f"Generating audio… {r} remaining")) + self._book_status_var.setText(f"Generating audio… {remaining} remaining") manifest_path.write_text( json.dumps(manifest, ensure_ascii=False, indent=2)) - self._safe_after(0, lambda: - self._finish_extract(book, manifest, done, failed)) + self._finish_extract(book, manifest, done, failed) except ImportError as exc: msg = (f"Missing dependency: {exc}\n\n" "Install with: pip install spacy wordfreq\n" "Then: python -m spacy download en_core_web_sm") - self._safe_after(0, lambda m=msg: messagebox.showerror( - "Missing package", m)) - self._safe_after(0, lambda: self._book_status_var.set("Error — see popup")) - self._safe_after(0, lambda: self._extract_btn.config(state="normal")) + QMessageBox.critical(self, "Missing package", msg) + self._book_status_var.setText("Error — see popup") + self._extract_btn.setEnabled(True) except Exception as exc: err = str(exc) - self._safe_after(0, lambda e=err: self._book_status_var.set(f"Error: {e}")) - self._safe_after(0, lambda: self._extract_btn.config(state="normal")) + self._book_status_var.setText(f"Error: {err}") + self._extract_btn.setEnabled(True) threading.Thread(target=_run, daemon=True).start() def _finish_extract(self, book: BookSource, manifest: dict, done: int, failed: int) -> None: - self._extract_btn.config(state="normal") - self._book_status_var.set( + self._extract_btn.setEnabled(True) + self._book_status_var.setText( f"Done — {len(manifest)} words total ({done} new, {failed} failed)") if self.book and self.book.slug == book.slug: self._load_book(book) @@ -894,7 +1104,7 @@ class ProperNounAuditor(tk.Tk): if not self.book: return if not self.fixes: - messagebox.showinfo("No fixes", "The Fixes list is empty.") + QMessageBox.information(self, "No fixes", "The Fixes list is empty.") return replacements = list(self.fixes.values()) @@ -903,12 +1113,12 @@ class ProperNounAuditor(tk.Tk): already = sum(1 for r in replacements if (rdir / f"{_slug(r)}.wav").exists()) new_count = total - already if new_count == 0: - messagebox.showinfo("Already done", + QMessageBox.information(self, "Already done", f"All {total} replacement clips already exist.") return - self._pregen_btn.config(state="disabled") - self._pregen_status_var.set(f"0 / {new_count} new ({already} cached)") + self._pregen_btn.setEnabled(False) + self._pregen_status_var.setText(f"0 / {new_count} new ({already} cached)") def _run(): try: @@ -917,14 +1127,12 @@ class ProperNounAuditor(tk.Tk): if not (rdir / f"{_slug(rep)}.wav").exists(): _synth_to_cache(rep, rdir) done += 1 - self._safe_after(0, lambda d=done, t=new_count: - self._pregen_status_var.set(f"{d} / {t} synthesised…")) - self._safe_after(0, lambda: - self._pregen_status_var.set(f"Done — {total} clips ready")) + self._pregen_status_var.setText(f"{done} / {new_count} synthesised…") + self._pregen_status_var.setText(f"Done — {total} clips ready") except Exception as exc: print(f"[pregen] error: {exc}") finally: - self._safe_after(0, lambda: self._pregen_btn.config(state="normal")) + self._pregen_btn.setEnabled(True) threading.Thread(target=_run, daemon=True).start() @@ -933,23 +1141,23 @@ class ProperNounAuditor(tk.Tk): return words = self._review_words() if not words: - messagebox.showinfo("Nothing to export", "No words left to review.") + QMessageBox.information(self, "Nothing to export", "No words left to review.") return out = self._data_dir / "remaining_review.txt" out.write_text("\n".join(words), encoding="utf-8") - messagebox.showinfo("Exported", f"{len(words)} words written to:\n{out}") + QMessageBox.information(self, "Exported", f"{len(words)} words written to:\n{out}") def _apply_fixes(self) -> None: if not self.book: return if not self.fixes: - messagebox.showinfo("No fixes", "The Fixes list is empty.") + QMessageBox.information(self, "No fixes", "The Fixes list is empty.") return parts = [] for p in self.book.source_paths: if not p.exists(): - messagebox.showerror("Source not found", f"Cannot find:\n{p}") + QMessageBox.critical(self, "Source not found", f"Cannot find:\n{p}") return parts.append(p.read_text(encoding="utf-8")) text = "\n\n".join(parts) @@ -969,13 +1177,94 @@ class ProperNounAuditor(tk.Tk): ) self.book.fixed_out.write_text(text, encoding="utf-8") - messagebox.showinfo( - "Done", + QMessageBox.information( + self, "Done", f"Applied {len(self.fixes)} fix rules ({count_total} replacements).\n" f"Converted {n_caps} ALL-CAPS words to Title Case.\n\n" f"Saved to:\n{self.book.fixed_out}" ) + def _set_gen_audio_status(self, text: str) -> None: + QTimer.singleShot(0, lambda: self._gen_audio_status.setText(text)) + + def _set_gen_audio_enabled(self, enabled: bool) -> None: + QTimer.singleShot(0, lambda: self._gen_audio_btn.setEnabled(enabled)) + + def _generate_selected_chapters(self) -> None: + """Generate chapter audio from source files with selected voice and chapter set.""" + if not self.book: + return + + missing = [p for p in self.book.source_paths if not p.exists()] + if missing: + QMessageBox.critical(self, "Source file(s) not found", "Could not find:\n" + "\n".join(str(p) for p in missing)) + return + + voice = self._voice_combo.currentText().strip() or "am_onyx" + chapter_expr = self._chapters_entry.text().strip() or "all" + out_dir = Path("output_audiobook") / self.book.slug + out_dir.mkdir(parents=True, exist_ok=True) + + self._gen_audio_btn.setEnabled(False) + self._set_gen_audio_status("Parsing chapters…") + + def _run() -> None: + try: + chapters = _parse_chapters_from_paths(self.book.source_paths) + if not chapters: + self._set_gen_audio_status("No chapters found (expected 'Prologue' or 'Chapter # - chapter name').") + return + + valid = {ch["num"] for ch in chapters} + selected_nums = _parse_chapter_selection(chapter_expr, valid) + if not selected_nums: + self._set_gen_audio_status("No matching chapters selected.") + return + + selected = [ch for ch in chapters if ch["num"] in selected_nums] + pipeline = _get_pipeline() + total = len(selected) + done = 0 + + for i, ch in enumerate(selected, start=1): + text = _clean_tts_text(ch["text"]) + if not text: + continue + + self._set_gen_audio_status(f"Generating {i}/{total}: {ch['label']}") + out_path = out_dir / f"{ch['slug']}.wav" + + t0 = time.monotonic() + chunks = [] + import numpy as np + for _, _, chunk_audio in pipeline(text, voice=voice): + if chunk_audio is None: + continue + if hasattr(chunk_audio, "numpy"): + chunk_audio = chunk_audio.cpu().numpy() + chunk_audio = np.atleast_1d(chunk_audio.squeeze()) + if chunk_audio.size > 0: + chunks.append(chunk_audio) + + if chunks: + audio = np.concatenate(chunks, axis=0) + sf.write(str(out_path), audio, SAMPLE_RATE) + elapsed = int(time.monotonic() - t0) + done += 1 + self._set_gen_audio_status( + f"Saved {done}/{total}: {out_path.name} ({elapsed}s)" + ) + + self._set_gen_audio_status(f"Done. Generated {done}/{total} chapters to {out_dir}") + except ValueError: + self._set_gen_audio_status("Invalid chapter selection. Use: all or 0,1,2,5-8") + except Exception as exc: + self._set_gen_audio_status(f"Error: {exc}") + finally: + self._set_gen_audio_enabled(True) + + threading.Thread(target=_run, daemon=True).start() + # ── Standalone NLP extraction (lazy-imports spaCy) ───────────────────────────────── @@ -1062,17 +1351,15 @@ def _extract_nouns_from_paths(source_paths: list) -> set[str]: # ── Entry point ────────────────────────────────────────────────────────────────── def main() -> None: - books = discover_books() - if not books: - print("No source text files found in the current directory.") - raise SystemExit(1) - - print(f"Discovered {len(books)} book source(s):") + books = load_books_from_projects() + print(f"Loaded {len(books)} project(s):") for b in books: print(f" [{b.slug}] {b.label} ({len(b.source_paths)} file(s))") - app = ProperNounAuditor(books) - app.mainloop() + app = QApplication(sys.argv) + window = ProperNounAuditor(books) + window.show() + sys.exit(app.exec()) if __name__ == "__main__": diff --git a/output_proper_nouns/visions_glory_canada/manifest.json b/output_proper_nouns/visions_glory_canada/manifest.json new file mode 100644 index 0000000..3c841f7 --- /dev/null +++ b/output_proper_nouns/visions_glory_canada/manifest.json @@ -0,0 +1,30 @@ +{ + "Adam": "adam.wav", + "Adam-Ondi-Ahman": "adam_ondi_ahman.wav", + "Ahman": "ahman.wav", + "Alma": "alma.wav", + "Apostles": "apostles.wav", + "Brethren": "brethren.wav", + "Cardston": "cardston.wav", + "Ephraim": "ephraim.wav", + "Evolving": "evolving.wav", + "Holies": "holies.wav", + "Israel": "israel.wav", + "Joseph": "joseph.wav", + "Knelt": "knelt.wav", + "Lehi": "lehi.wav", + "Liahona": "liahona.wav", + "Millennium": "millennium.wav", + "Mormon": "mormon.wav", + "Moroni": "moroni.wav", + "Mosiah": "mosiah.wav", + "Nauvoo": "nauvoo.wav", + "Quorum": "quorum.wav", + "Rachael": "rachael.wav", + "Savior": "savior.wav", + "Thummim": "thummim.wav", + "Urim": "urim.wav", + "Vignette": "vignette.wav", + "Zachary": "zachary.wav", + "Zion": "zion.wav" +} \ No newline at end of file diff --git a/projects.json b/projects.json new file mode 100644 index 0000000..3569c99 --- /dev/null +++ b/projects.json @@ -0,0 +1,14 @@ +[ + { + "name": "Audio Text for Novel Lightbringer", + "source_paths": [ + "/home/dillon/_code/voice_model/Audio Text for Novel Lightbringer/Audio Text for Novel Lightbringer.txt" + ] + }, + { + "name": "visions glory canada", + "source_paths": [ + "/home/dillon/_code/voice_model/Visions of Glory_ Zion in Canada pg 162-193.txt" + ] + } +] \ No newline at end of file diff --git a/setup_windows.bat b/setup_windows.bat index 0114af1..064d438 100644 --- a/setup_windows.bat +++ b/setup_windows.bat @@ -14,7 +14,7 @@ if errorlevel 1 ( echo. echo ERROR: Python was not found. echo. - echo Please install Python 3.11 from https://www.python.org/downloads/ + echo Please install Python 3.12 from https://www.python.org/downloads/ echo IMPORTANT: On the installer, tick "Add Python to PATH" before clicking Install. echo. echo After installing, close this window and double-click setup_windows.bat again.