Remove legacy tkinter GUI and align CLI with NiceGUI version
- Delete src/gui.py, src/gui_parts/, scripts/run_tkinter.{sh,py}
- Update scripts/run.sh to launch NiceGUI instead of tkinter
- Rewrite tui.py to match NiceGUI feature set: AI processing
(persona, model, trigger modes), voice commands, auto-stop,
autotype, SessionLogger (JSONL), and dynamic multi-pane layout
- Update all documentation to remove tkinter references
https://claude.ai/code/session_01QWFAoNzByJY4n4FtEiYCCo
+426 −2079 · 13 files changed
-
AI_SETUP.md+11 −3 -
README.md+9 −7 -
STRUCTURE.md+30 −41 -
scripts/README.md+6 −20 -
scripts/run.sh+18 −6 -
scripts/run_tkinter.py+0 −21 -
scripts/run_tkinter.sh+0 −23 -
src/gui.py+0 −1638 -
src/gui_parts/__init__.py+0 −0 -
src/gui_parts/help_dialog.py+0 −135 -
src/gui_parts/text_widget.py+0 −99 -
src/tui.py+351 −78 -
src/whispering_ui/README.md+1 −8
@@ -98,7 +98,7 @@ Testing translation mode... 1. Start Whispering: ```bash - python gui.py + ./scripts/run.sh ``` 2. Configure transcription settings as usual (mic, model, etc.) @@ -117,6 +117,13 @@ Testing translation mode... 5. Click **Start** and begin speaking! +### CLI Mode + +Use the `--ai` flag along with persona and model selection: +```bash +python src/tui.py --ai --ai-persona proofread --target es +``` + ### How It Works ``` @@ -307,8 +314,9 @@ Variables you can use: To use traditional Google Translate instead: -1. Simply uncheck the "AI" checkbox in the GUI -2. The app will fall back to the original translation method +1. In the GUI, simply uncheck the "AI" checkbox +2. In the CLI, omit the `--ai` flag +3. The app will fall back to the original translation method You can switch between AI and Google Translate at any time! @@ -85,21 +85,22 @@ The installer automatically detects your GPU, installs cuDNN libraries matching ### Running the App -**Modern UI (Recommended)** +**GUI (Recommended)** A responsive, dark-themed web interface built with NiceGUI. ```bash -./scripts/run_nicegui.sh +./scripts/run.sh +# or: ./scripts/run_nicegui.sh ``` -**Legacy GUI** -Classic desktop interface (Tkinter). +**CLI (Terminal UI)** +Curses-based terminal interface with AI, voice commands, auto-stop, and session logging. ```bash -./scripts/run_tkinter.sh +python src/tui.py --help ``` ## Interface Guide -The Modern UI is divided into a control sidebar and dynamic output panels ("Whisper", "AI", "Translation"). +The GUI is divided into a control sidebar and dynamic output panels ("Whisper", "AI", "Translation"). - **Microphone**: Select your input device and monitor audio levels. - **Model Settings**: Choose Whisper model size (Tiny to Large-v3) and device (CUDA/CPU). @@ -175,10 +176,11 @@ Whispering/ ├── logs/ # Session logs organized by date ├── scripts/ # Install & Run scripts ├── src/ -│ ├── whispering_ui/ # Modern NiceGUI Application +│ ├── whispering_ui/ # NiceGUI Application (GUI) │ │ ├── components/ # UI Widgets (Sidebar, Output, Help) │ │ ├── bridge.py # UI <-> Core Logic Bridge │ │ └── main.py # App Entry Point +│ ├── tui.py # Terminal User Interface (CLI) │ ├── core_parts/ # Audio Device & Signal Processing │ ├── ai_provider.py # OpenRouter AI Integration │ ├── commands_config.py # Voice commands YAML loader @@ -8,35 +8,40 @@ This document describes the organization of the Whispering project. Whispering/ ├── src/ # Source code │ ├── core.py # Core transcription engine -│ ├── gui.py # Graphical user interface -│ ├── tui.py # Terminal user interface +│ ├── tui.py # Terminal user interface (CLI) +│ ├── whispering_ui/ # NiceGUI application (GUI) +│ │ ├── main.py # App entry point +│ │ ├── state.py # Application state model +│ │ ├── bridge.py # UI <-> Core logic bridge +│ │ └── components/ # UI widgets (sidebar, output, help) │ ├── ai_config.py # AI configuration loader │ ├── ai_provider.py # AI text processing +│ ├── commands_config.py # Voice commands YAML loader +│ ├── command_detector.py # Voice command pattern matching +│ ├── command_executor.py # Voice command action dispatcher │ ├── tts_controller.py # Text-to-speech controller │ ├── tts_provider.py # TTS provider interface │ ├── autotype.py # Auto-typing functionality │ ├── settings.py # Settings persistence │ ├── cmque.py # Custom queue implementation -│ ├── transcript_logger.py # Transcript logging to files +│ ├── session_logger.py # JSONL session logging & recovery │ ├── debug_audio.py # Audio debugging utility │ └── debug_cuda.py # CUDA debugging utility │ ├── config/ # Configuration files │ ├── ai_config.yaml # AI model configuration +│ ├── custom_personas.yaml # Custom AI assistants +│ ├── voice_commands.yaml # Voice command definitions │ └── .env.example # Environment variables template │ ├── scripts/ # Shell scripts │ ├── install.sh # Installation script -│ ├── run.sh # Main launcher script +│ ├── run.sh # Default launcher (NiceGUI) +│ ├── run_nicegui.sh # NiceGUI launcher +│ ├── run_nicegui.py # NiceGUI Python launcher │ └── debug_env.sh # Environment debugging │ -├── log_output/ # Transcript log files (gitignored) -│ └── transcript_*.txt # Auto-generated session logs -│ -├── docs/ # Documentation (in root for visibility) -│ ├── README.md # Main documentation -│ ├── AI_SETUP.md # AI features setup guide -│ └── INSTALL_TTS.md # TTS installation guide +├── logs/ # Session logs organized by date (gitignored) │ ├── requirements.txt # Python dependencies ├── LICENSE # Project license @@ -47,12 +52,14 @@ Whispering/ ### Source Code (`src/`) All Python source files are located in the `src/` directory. The main entry points are: -- `gui.py` - Main graphical interface (run with `scripts/run.sh`) +- `whispering_ui/main.py` - GUI interface (run with `scripts/run.sh`) - `tui.py` - Terminal interface (run with `python src/tui.py`) ### Configuration (`config/`) Configuration files that control application behavior: - `ai_config.yaml` - AI model settings and prompts +- `custom_personas.yaml` - Custom AI assistants +- `voice_commands.yaml` - Voice command definitions - `.env.example` - Template for environment variables (copy to `.env` in project root) ### Scripts (`scripts/`) @@ -61,16 +68,8 @@ Helper scripts for setup and running the application: - `run.sh` - Launch the GUI with proper environment setup - `debug_env.sh` - Diagnose CUDA and audio issues -### Log Output (`log_output/`) -Automatically created directory for transcript logs. Files are named: -``` -transcript_YYYYMMDD_HHMMSS.txt -``` - -Each log file includes: -- Session start/end timestamps -- Duration -- Timestamped transcript entries +### Logs (`logs/`) +Session logs in JSONL format, organized by date (YY/MM/DD/). **Note:** This directory is gitignored to keep your transcripts private. @@ -82,7 +81,7 @@ Each log file includes: ``` The install script will: -- Check Python version (3.8+ required) +- Check Python version (3.10+ required) - Create virtual environment - Install all dependencies - Optionally install CUDA libraries @@ -98,28 +97,18 @@ The install script will: ### TUI Mode ```bash -# With logging (default) +# Basic usage python src/tui.py -# Without logging -python src/tui.py --no-log +# With AI processing +python src/tui.py --ai --ai-persona proofread -# List recent logs while running -Press 'L' in TUI -``` - -## New Features +# With voice commands and auto-stop +python src/tui.py --voice-commands --auto-stop 10 -### Transcript Logging -- **Automatic logging** - All transcriptions are saved to `log_output/` -- **Session-based** - Each start/stop creates a new log file -- **Timestamped** - Each entry includes time of transcription -- **Presentable format** - Includes headers, footers, and session duration - -### TUI Enhancements -- Press `L` to list recent log files -- Status shows "(Logging)" when recording -- Clean session management +# See all options +python src/tui.py --help +``` ## Development @@ -4,34 +4,20 @@ This directory contains all launcher and utility scripts for Whispering. ## Launcher Scripts -### NiceGUI Interface (New) +### GUI (NiceGUI Interface) +- **run.sh** - Default launcher for Whispering (NiceGUI) - **run_nicegui.sh** - Bash launcher for NiceGUI interface - **run_nicegui.py** - Python launcher for NiceGUI interface The NiceGUI interface provides a modern, modular UI with clean separation of concerns. ```bash -# Bash launcher -./scripts/run_nicegui.sh - -# Or Python launcher -./scripts/run_nicegui.py -``` - -### Tkinter Interface (Legacy) -- **run.sh** - Original Bash launcher for tkinter interface -- **run_tkinter.sh** - Alternative Bash launcher for tkinter interface -- **run_tkinter.py** - Python launcher for tkinter interface - -The tkinter interface is the original monolithic GUI. - -```bash -# Original launcher +# Default launcher ./scripts/run.sh -# Or alternative launchers -./scripts/run_tkinter.sh -./scripts/run_tkinter.py +# Or use the NiceGUI-specific launchers +./scripts/run_nicegui.sh +./scripts/run_nicegui.py ``` ## Installation Scripts @@ -1,10 +1,15 @@ #!/bin/bash +# Run Whispering with NiceGUI interface (default launcher) + PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # Prefer PulseAudio/PipeWire over ALSA for audio export SDL_AUDIODRIVER=pulse -source "$PROJECT_DIR/.venv/bin/activate" +# Activate virtual environment if it exists +if [ -d "$PROJECT_DIR/.venv" ]; then + source "$PROJECT_DIR/.venv/bin/activate" +fi # Ensure all NVIDIA/cuDNN libraries load from the SAME pip package to prevent # CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH. This must happen BEFORE Python @@ -22,11 +27,18 @@ if [ -n "$NVIDIA_LIBS" ]; then export LD_LIBRARY_PATH="$NVIDIA_LIBS${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" fi +# Check if NiceGUI is installed +if ! python3 -c "import nicegui" 2>/dev/null; then + echo "Error: NiceGUI is not installed." + echo "Please install dependencies:" + echo " $PROJECT_DIR/.venv/bin/python3 -m pip install nicegui pyperclip" + exit 1 +fi + # Add src to Python path export PYTHONPATH="$PROJECT_DIR/src:$PYTHONPATH" -# Debug mode: uncomment to see full errors -# python "$PROJECT_DIR/src/gui.py" "$@" - -# Production mode: filter ALSA noise -python "$PROJECT_DIR/src/gui.py" "$@" 2> >(grep -v "^ALSA lib\|^Expression '" >&2) +# Change to src directory and run +cd "$PROJECT_DIR/src" +echo "Starting Whispering with NiceGUI interface..." +python3 whispering_ui/main.py "$@" @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -""" -Launch script for Whispering with tkinter interface (legacy) -""" - -import sys -from pathlib import Path - -# Add src to path -src_dir = Path(__file__).parent / "src" -sys.path.insert(0, str(src_dir)) - -# Launch the tkinter GUI -print("Starting Whispering with tkinter interface...") -from gui import App - -if __name__ == "__main__": - try: - App().mainloop() - except KeyboardInterrupt: - pass @@ -1,23 +0,0 @@ -#!/bin/bash -# Run Whispering with tkinter interface (legacy) - -# Get the project directory (parent of scripts directory) -PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -# Activate virtual environment if it exists -if [ -d "$PROJECT_DIR/.venv" ]; then - source "$PROJECT_DIR/.venv/bin/activate" -fi - -# Prefer PulseAudio/PipeWire over ALSA for audio -export SDL_AUDIODRIVER=pulse - -# Add src to Python path -export PYTHONPATH="$PROJECT_DIR/src:$PYTHONPATH" - -# Change to src directory and run -cd "$PROJECT_DIR/src" -echo "Starting Whispering with tkinter interface..." - -# Filter ALSA noise -python3 gui.py "$@" 2> >(grep -v "^ALSA lib\|^Expression '" >&2) @@ -1,1638 +0,0 @@ -#!/usr/bin/env python3 - - -import threading -import tkinter as tk -import tkinter.ttk as ttk -from tkinter import filedialog, messagebox - -import core -from settings import Settings -from debug import set_debug_enabled -from gui_parts.help_dialog import HelpDialog, MODEL_VRAM -from gui_parts.text_widget import Text - - - - - -class App(tk.Tk): - def __init__(self): - super().__init__() - self.title("Whispering") - self.autotype_mode_active = "Off" # Track autotype mode: Off, Whisper, Translation, AI - - # Load settings first - self.settings = Settings() - - # Load debug mode from settings (default: False) - self.debug_enabled = self.settings.get("debug_enabled", False) - set_debug_enabled(self.debug_enabled) - - # Load text visibility state from settings (default: False - start in minimal mode) - self.text_visible = self.settings.get("text_visible", False) - - # Flag to track if we're shutting down (prevent TTS crashes) - self.is_shutting_down = False - - # Set minimum window size - will adjust based on mode - # For minimal mode: calculate based on controls, for full mode add space for text - # Minimal mode: 400x1000 minimum (never go below this) - # Full mode: 900x1000 gives space for text panels - if self.text_visible: - self.minsize(900, 1000) # Full mode - more vertical space for text - self.maxsize(10000, 1000) # Limit max height to 1000px - else: - self.minsize(400, 1000) # Minimal mode - never below 400x1000 - self.maxsize(10000, 1000) # Limit max height to 1000px - - # Try to load AI configuration - self.ai_config = None - self.ai_available = False - try: - from ai_config import load_ai_config - self.ai_config = load_ai_config() - self.ai_available = self.ai_config is not None - except Exception as e: - print(f"AI features not available: {e}") - - # Try to initialize TTS - self.tts_controller = None - self.tts_available = False - try: - from tts_controller import TTSController - self.tts_controller = TTSController(device="auto", output_dir="tts_output") - self.tts_controller.on_progress = self.on_tts_progress - self.tts_controller.on_complete = self.on_tts_complete - self.tts_controller.on_error = self.on_tts_error - self.tts_available = True - except Exception as e: - print(f"TTS features not available: {e}") - - # Create frames - two column layout - self.controls_frame = ttk.Frame(self, padding="5") - - # Text frame with labels - self.text_frame = ttk.Frame(self) - - # Whisper output (top) - header frame with label and count - ts_header = ttk.Frame(self.text_frame) - ts_header.grid(row=0, column=0, sticky="ew", padx=5, pady=(0, 2)) - ts_label = ttk.Label(ts_header, text="Whisper Output", font=('TkDefaultFont', 9, 'bold')) - ts_label.pack(side="left") - self.ts_count_label = ttk.Label(ts_header, text="0 chars, 0 words", font=('TkDefaultFont', 8), foreground="gray") - self.ts_count_label.pack(side="right") - - # Create frame for text widget and scrollbar - ts_frame = ttk.Frame(self.text_frame) - ts_frame.grid(row=1, column=0, sticky="nsew") - ts_frame.columnconfigure(0, weight=1) - ts_frame.rowconfigure(0, weight=1) - - self.ts_text = Text(ts_frame, on_new_text=self.on_new_transcription, on_text_changed=self.update_ts_count) - self.ts_text.grid(row=0, column=0, sticky="nsew") - - ts_scrollbar = ttk.Scrollbar(ts_frame, command=self.ts_text.yview) - ts_scrollbar.grid(row=0, column=1, sticky="ns") - self.ts_text.config(yscrollcommand=ts_scrollbar.set) - - # AI processed output (middle) - header frame with label and count - pr_header = ttk.Frame(self.text_frame) - pr_header.grid(row=2, column=0, sticky="ew", padx=5, pady=(5, 2)) - pr_label = ttk.Label(pr_header, text="AI Output", font=('TkDefaultFont', 9, 'bold')) - pr_label.pack(side="left") - self.pr_count_label = ttk.Label(pr_header, text="0 chars, 0 words", font=('TkDefaultFont', 8), foreground="gray") - self.pr_count_label.pack(side="right") - - # Create frame for text widget and scrollbar - pr_frame = ttk.Frame(self.text_frame) - pr_frame.grid(row=3, column=0, sticky="nsew") - pr_frame.columnconfigure(0, weight=1) - pr_frame.rowconfigure(0, weight=1) - - self.pr_text = Text(pr_frame, on_new_text=self.on_new_proofread, on_text_changed=self.update_pr_count) - self.pr_text.grid(row=0, column=0, sticky="nsew") - - pr_scrollbar = ttk.Scrollbar(pr_frame, command=self.pr_text.yview) - pr_scrollbar.grid(row=0, column=1, sticky="ns") - self.pr_text.config(yscrollcommand=pr_scrollbar.set) - - # Disable proofread window by default (enable when AI proofread+translate is active) - # Keep visible but grayed out - self.pr_text.config(state="disabled", background="#f0f0f0") - - # Translation output (bottom) - header frame with label and count - tl_header = ttk.Frame(self.text_frame) - tl_header.grid(row=4, column=0, sticky="ew", padx=5, pady=(5, 2)) - tl_label = ttk.Label(tl_header, text="Translation Output", font=('TkDefaultFont', 9, 'bold')) - tl_label.pack(side="left") - self.tl_count_label = ttk.Label(tl_header, text="0 chars, 0 words", font=('TkDefaultFont', 8), foreground="gray") - self.tl_count_label.pack(side="right") - - # Create frame for text widget and scrollbar - tl_frame = ttk.Frame(self.text_frame) - tl_frame.grid(row=5, column=0, sticky="nsew") - tl_frame.columnconfigure(0, weight=1) - tl_frame.rowconfigure(0, weight=1) - - self.tl_text = Text(tl_frame, on_new_text=self.on_new_translation, on_text_changed=self.update_tl_count) - self.tl_text.grid(row=0, column=0, sticky="nsew") - - tl_scrollbar = ttk.Scrollbar(tl_frame, command=self.tl_text.yview) - tl_scrollbar.grid(row=0, column=1, sticky="ns") - self.tl_text.config(yscrollcommand=tl_scrollbar.set) - - # Store references to show/hide proofread window dynamically - self.pr_header = pr_header - - # Configure text_frame grid - self.text_frame.columnconfigure(0, weight=1) - self.text_frame.rowconfigure(1, weight=1) # Whisper - self.text_frame.rowconfigure(3, weight=1) # Proofread - self.text_frame.rowconfigure(5, weight=1) # Translation - - # Grid layout: controls in column 0, text frame in column 1 - self.controls_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 5)) - - # Hide or show text frame based on initial state - if self.text_visible: - self.text_frame.grid(row=0, column=1, sticky="nsew") - # else: don't grid it (starts hidden in minimal mode) - - # Configure grid weights based on initial mode - if self.text_visible: - self.columnconfigure(0, weight=0, minsize=350) # Controls column - fixed width - self.columnconfigure(1, weight=1) # Text column - expandable - else: - self.columnconfigure(0, weight=1, minsize=350) # Controls column expands in minimal mode - self.columnconfigure(1, weight=0) # Text column hidden - - self.rowconfigure(0, weight=1) # Main row for both frames - - # Row counter for controls_frame - row = 0 - - # === MIC SECTION === - mic_frame = ttk.Frame(self.controls_frame) - mic_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - ttk.Label(mic_frame, text="Mic:").pack(side="left", padx=(0, 5)) - self.mic_list = core.get_mic_names() - mic_display = ["(system default)"] + [name for idx, name in self.mic_list] - self.mic_combo = ttk.Combobox(mic_frame, values=mic_display, state="readonly", width=25) - self.mic_combo.current(0) - self.mic_combo.pack(side="left", fill="x", expand=True, padx=(0, 5)) - self.mic_button = ttk.Button(mic_frame, text="↻", width=2, command=self.refresh_mics) - self.mic_button.pack(side="left") - - # === TOGGLE BUTTON === - # Set initial button text based on visibility state - button_text = "Hide Text ◀" if self.text_visible else "Show Text ▶" - self.hide_text_button = ttk.Button(self.controls_frame, text=button_text, command=self.toggle_text_display) - self.hide_text_button.grid(row=row, column=0, sticky="ew", pady=(0, 10)) - row += 1 - - # === MODEL SECTION === - ttk.Separator(self.controls_frame, orient="horizontal").grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - # Section header with help button - model_header = ttk.Frame(self.controls_frame) - model_header.grid(row=row, column=0, sticky="ew", pady=(0, 3)) - ttk.Label(model_header, text="Model Settings Speech-to-Text", font=('TkDefaultFont', 9, 'bold')).pack(side="left") - help_btn = ttk.Button(model_header, text="?", width=2, command=lambda: HelpDialog.show(self, "model")) - help_btn.pack(side="right") - row += 1 - - model_frame = ttk.Frame(self.controls_frame) - model_frame.grid(row=row, column=0, sticky="ew", pady=(0, 3)) - row += 1 - - ttk.Label(model_frame, text="Model:").pack(side="left", padx=(0, 5)) - self.model_combo = ttk.Combobox(model_frame, values=core.models, state="normal", width=15) - self.model_combo.set("large-v3") - self.model_combo.pack(side="left", fill="x", expand=True) - self.model_combo.bind("<<ComboboxSelected>>", self.on_model_changed) - - # VRAM info label - self.vram_label = ttk.Label(self.controls_frame, text=MODEL_VRAM.get("large-v3", ""), - foreground="gray", font=('TkDefaultFont', 8)) - self.vram_label.grid(row=row, column=0, sticky="w", pady=(0, 5)) - row += 1 - - # VAD, ¶, ⌨, Device grouped - options_frame = ttk.Frame(self.controls_frame) - options_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - self.vad_check = ttk.Checkbutton(options_frame, text="VAD", onvalue=True, offvalue=False) - self.vad_check.state(("!alternate", "selected")) - self.vad_check.pack(side="left", padx=(0, 8)) - - self.para_check = ttk.Checkbutton(options_frame, text="¶", onvalue=True, offvalue=False) - self.para_check.state(("!alternate", "selected")) - self.para_check.pack(side="left", padx=(0, 8)) - - ttk.Label(options_frame, text="⌨:").pack(side="left", padx=(0, 2)) - self.autotype_mode = ttk.Combobox(options_frame, values=["Off", "Whisper", "Translation", "AI"], - state="readonly", width=10) - self.autotype_mode.current(0) # Default to "Off" - self.autotype_mode.pack(side="left", padx=(0, 8)) - - ttk.Label(options_frame, text="Dev:").pack(side="left", padx=(0, 2)) - self.device_combo = ttk.Combobox(options_frame, values=core.devices, state="readonly", width=6) - self.device_combo.current(1) - self.device_combo.pack(side="left") - - # === MEMORY, PATIENCE, TIMEOUT === - params_frame = ttk.Frame(self.controls_frame) - params_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - ttk.Label(params_frame, text="Mem:").pack(side="left", padx=(0, 2)) - self.memory_spin = ttk.Spinbox(params_frame, from_=1, to=10, increment=1, state="readonly", width=4) - self.memory_spin.set(3) - self.memory_spin.pack(side="left", padx=(0, 8)) - - ttk.Label(params_frame, text="Pat:").pack(side="left", padx=(0, 2)) - self.patience_spin = ttk.Spinbox(params_frame, from_=1.0, to=20.0, increment=0.5, state="readonly", width=4) - self.patience_spin.set(5.0) - self.patience_spin.pack(side="left", padx=(0, 8)) - - ttk.Label(params_frame, text="Time:").pack(side="left", padx=(0, 2)) - self.timeout_spin = ttk.Spinbox(params_frame, from_=1.0, to=20.0, increment=0.5, state="readonly", width=4) - self.timeout_spin.set(5.0) - self.timeout_spin.pack(side="left") - - # Whisper output Copy/Cut buttons with count - whisper_buttons_frame = ttk.Frame(self.controls_frame) - whisper_buttons_frame.grid(row=row, column=0, sticky="ew", pady=(5, 0)) - row += 1 - - ttk.Button(whisper_buttons_frame, text="Copy Whisper", command=self.copy_whisper_text, width=15).pack(side="left", padx=(0, 5)) - ttk.Button(whisper_buttons_frame, text="Cut Whisper", command=self.cut_whisper_text, width=15).pack(side="left", padx=(0, 5)) - self.whisper_count_button = ttk.Label(whisper_buttons_frame, text="0c, 0w", foreground="blue", font=('TkDefaultFont', 8)) - self.whisper_count_button.pack(side="left") - - # === TRANSLATE/PROOFREAD SECTION === - ttk.Separator(self.controls_frame, orient="horizontal").grid(row=row, column=0, sticky="ew", pady=(5, 5)) - row += 1 - - # Section header with help button - translate_header = ttk.Frame(self.controls_frame) - translate_header.grid(row=row, column=0, sticky="ew", pady=(0, 3)) - ttk.Label(translate_header, text="Translation", font=('TkDefaultFont', 9, 'bold')).pack(side="left") - help_btn = ttk.Button(translate_header, text="?", width=2, command=lambda: HelpDialog.show(self, "translate")) - help_btn.pack(side="right") - row += 1 - - lang_frame = ttk.Frame(self.controls_frame) - lang_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - ttk.Label(lang_frame, text="Source:").pack(side="left", padx=(0, 2)) - self.source_combo = ttk.Combobox(lang_frame, values=["auto"] + core.sources, state="readonly", width=5) - self.source_combo.current(0) - self.source_combo.pack(side="left", padx=(0, 10)) - - ttk.Label(lang_frame, text="Target:").pack(side="left", padx=(0, 2)) - self.target_combo = ttk.Combobox(lang_frame, values=["none"] + core.targets, state="readonly", width=5) - self.target_combo.current(0) - self.target_combo.bind("<<ComboboxSelected>>", self.on_target_changed) - self.target_combo.pack(side="left") - - # Translation output Copy/Cut buttons with count - translation_buttons_frame = ttk.Frame(self.controls_frame) - translation_buttons_frame.grid(row=row, column=0, sticky="ew", pady=(0, 10)) - row += 1 - - ttk.Button(translation_buttons_frame, text="Copy Translation", command=self.copy_translation_text, width=15).pack(side="left", padx=(0, 5)) - ttk.Button(translation_buttons_frame, text="Cut Translation", command=self.cut_translation_text, width=15).pack(side="left", padx=(0, 5)) - self.translation_count_button = ttk.Label(translation_buttons_frame, text="0c, 0w", foreground="blue", font=('TkDefaultFont', 8)) - self.translation_count_button.pack(side="left") - - # === AI SECTION === - ttk.Separator(self.controls_frame, orient="horizontal").grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - # Section header with help button - ai_header = ttk.Frame(self.controls_frame) - ai_header.grid(row=row, column=0, sticky="ew", pady=(0, 3)) - ttk.Label(ai_header, text="AI Processing", font=('TkDefaultFont', 9, 'bold')).pack(side="left") - if self.ai_available: - help_btn = ttk.Button(ai_header, text="?", width=2, command=lambda: HelpDialog.show(self, "ai")) - help_btn.pack(side="right") - row += 1 - - ai_enable_frame = ttk.Frame(self.controls_frame) - ai_enable_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - self.ai_check = ttk.Checkbutton(ai_enable_frame, text="Enable AI", onvalue=True, offvalue=False) - if self.ai_available: - self.ai_check.state(("!alternate",)) - else: - self.ai_check.state(("disabled",)) - self.ai_check.pack(side="left", padx=(0, 5)) - - # AI Persona/Task - ai_persona_frame = ttk.Frame(self.controls_frame) - ai_persona_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - ttk.Label(ai_persona_frame, text="Task:").pack(side="left", padx=(0, 2)) - if self.ai_available: - personas = self.ai_config.get_personas() - persona_names = [p['name'] for p in personas] - self.persona_ids = [p['id'] for p in personas] # Store IDs for mapping - else: - persona_names = ["Proofread"] - self.persona_ids = ["proofread"] - - self.ai_persona_combo = ttk.Combobox(ai_persona_frame, values=persona_names, state="readonly", width=18) - self.ai_persona_combo.current(0) - if not self.ai_available: - self.ai_persona_combo.state(("disabled",)) - self.ai_persona_combo.pack(side="left", fill="x", expand=True) - - # AI Translate controls - ai_translate_frame = ttk.Frame(self.controls_frame) - ai_translate_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - self.ai_translate_check = ttk.Checkbutton(ai_translate_frame, text="Translate output", onvalue=True, offvalue=False, command=self.on_translate_mode_changed) - if self.ai_available: - self.ai_translate_check.state(("!alternate",)) - else: - self.ai_translate_check.state(("disabled",)) - self.ai_translate_check.pack(side="left", padx=(0, 10)) - - self.ai_translate_only_check = ttk.Checkbutton(ai_translate_frame, text="Translate Only (1:1)", onvalue=True, offvalue=False, command=self.on_translate_mode_changed) - if self.ai_available: - self.ai_translate_only_check.state(("!alternate",)) - else: - self.ai_translate_only_check.state(("disabled",)) - self.ai_translate_only_check.pack(side="left") - - # AI Model - ai_model_frame = ttk.Frame(self.controls_frame) - ai_model_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - ttk.Label(ai_model_frame, text="Model:").pack(side="left", padx=(0, 2)) - if self.ai_available: - model_names = [m['name'] for m in self.ai_config.get_models()] - default_model_id = self.ai_config.get_default_model() - default_idx = 0 - for i, m in enumerate(self.ai_config.get_models()): - if m['id'] == default_model_id: - default_idx = i - break - else: - model_names = [] - default_idx = 0 - - self.ai_model_combo = ttk.Combobox(ai_model_frame, values=model_names, state="readonly", width=15) - if model_names: - self.ai_model_combo.current(default_idx) - if not self.ai_available: - self.ai_model_combo.state(("disabled",)) - self.ai_model_combo.pack(side="left", fill="x", expand=True) - - # AI Trigger - Split into left (manual) and right (automatic) - ai_trigger_frame = ttk.Frame(self.controls_frame) - ai_trigger_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - # LEFT SIDE: Manual mode - manual_frame = ttk.Frame(ai_trigger_frame) - manual_frame.pack(side="left", fill="x", expand=False) - - self.ai_manual_mode_check = ttk.Checkbutton(manual_frame, text="Manual mode", onvalue=True, offvalue=False, command=self.on_trigger_mode_changed) - if self.ai_available: - self.ai_manual_mode_check.state(("!alternate",)) - else: - self.ai_manual_mode_check.state(("disabled",)) - self.ai_manual_mode_check.pack(side="top", anchor="w") - - self.ai_manual_button = ttk.Button(manual_frame, text="⚡ Process Now", width=12, command=self.manual_ai_trigger) - if not self.ai_available: - self.ai_manual_button.state(("disabled",)) - else: - self.ai_manual_button.state(("disabled",)) # Disabled by default (enable when manual mode is checked) - self.ai_manual_button.pack(side="top", anchor="w", pady=(2, 0)) - - # RIGHT SIDE: Automatic triggers - auto_frame = ttk.Frame(ai_trigger_frame) - auto_frame.pack(side="right", fill="x", expand=True) - - trigger_row = ttk.Frame(auto_frame) - trigger_row.pack(side="top", fill="x", anchor="e") - ttk.Label(trigger_row, text="Trigger:").pack(side="left", padx=(0, 2)) - self.ai_trigger_combo = ttk.Combobox(trigger_row, values=["Time", "Words"], state="readonly", width=7) - self.ai_trigger_combo.current(0) - self.ai_trigger_combo.bind("<<ComboboxSelected>>", self.on_trigger_changed) - if not self.ai_available: - self.ai_trigger_combo.state(("disabled",)) - self.ai_trigger_combo.pack(side="left") - - interval_row = ttk.Frame(auto_frame) - interval_row.pack(side="top", fill="x", pady=(2, 0), anchor="e") - self.ai_interval_label = ttk.Label(interval_row, text="Interval:") - self.ai_interval_label.pack(side="left", padx=(0, 2)) - # Intervals: 5, 10, 15, 20, 25, 30, 45 seconds, 60 (1min), 90 (1.5min), 120 (2min) - interval_values = ["5", "10", "15", "20", "25", "30", "45", "60", "90", "120"] - interval_labels = ["5s", "10s", "15s", "20s", "25s", "30s", "45s", "1m", "1.5m", "2m"] - self.ai_interval_combo = ttk.Combobox(interval_row, values=interval_labels, state="readonly", width=6) - self.ai_interval_combo.current(3) # Default to 20 seconds - self.ai_interval_values_map = dict(zip(interval_labels, interval_values)) # Map display to actual values - if not self.ai_available: - self.ai_interval_combo.state(("disabled",)) - self.ai_interval_combo.pack(side="left") - - self.ai_words_label = ttk.Label(interval_row, text=" words:") - self.ai_words_spin = ttk.Spinbox(interval_row, from_=50, to=500, increment=50, state="readonly", width=5) - self.ai_words_spin.set(150) - if not self.ai_available: - self.ai_words_spin.state(("disabled",)) - self.ai_words_spin.pack(side="left") - - # Store references for enable/disable - self.auto_trigger_widgets = [self.ai_trigger_combo, self.ai_interval_combo, self.ai_words_spin, self.ai_interval_label, self.ai_words_label] - - # AI output Copy/Cut buttons with count - ai_output_buttons_frame = ttk.Frame(self.controls_frame) - ai_output_buttons_frame.grid(row=row, column=0, sticky="ew", pady=(5, 5)) - row += 1 - - ttk.Button(ai_output_buttons_frame, text="Copy AI Output", command=self.copy_proofread_text, width=15).pack(side="left", padx=(0, 5)) - ttk.Button(ai_output_buttons_frame, text="Cut AI Output", command=self.cut_proofread_text, width=15).pack(side="left", padx=(0, 5)) - self.proofread_count_button = ttk.Label(ai_output_buttons_frame, text="0c, 0w", foreground="blue", font=('TkDefaultFont', 8)) - self.proofread_count_button.pack(side="left") - - # Store ref to show/hide when needed - self.ai_output_buttons_frame = ai_output_buttons_frame - # Hide by default (only show when AI is active) - ai_output_buttons_frame.grid_remove() - - # === TTS SECTION === - ttk.Separator(self.controls_frame, orient="horizontal").grid(row=row, column=0, sticky="ew", pady=(10, 5)) - row += 1 - - # Section header with help button - tts_header = ttk.Frame(self.controls_frame) - tts_header.grid(row=row, column=0, sticky="ew", pady=(0, 3)) - ttk.Label(tts_header, text="Text-to-Speech", font=('TkDefaultFont', 9, 'bold')).pack(side="left") - if self.tts_available: - help_btn = ttk.Button(tts_header, text="?", width=2, command=lambda: HelpDialog.show(self, "tts")) - help_btn.pack(side="right") - row += 1 - - tts_enable_frame = ttk.Frame(self.controls_frame) - tts_enable_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - self.tts_check = ttk.Checkbutton(tts_enable_frame, text="Enable TTS", onvalue=True, offvalue=False) - if self.tts_available: - self.tts_check.state(("!alternate",)) - else: - self.tts_check.state(("disabled",)) - self.tts_check.pack(side="left", padx=(0, 5)) - - # TTS Source selection (mutually exclusive) - tts_source_frame = ttk.Frame(self.controls_frame) - tts_source_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - ttk.Label(tts_source_frame, text="Source:").pack(side="left", padx=(0, 5)) - - self.tts_source_whisper = ttk.Checkbutton(tts_source_frame, text="W", onvalue=True, offvalue=False, - command=lambda: self.on_tts_source_changed("whisper")) - if self.tts_available: - self.tts_source_whisper.state(("!alternate", "selected")) # Default to Whisper - else: - self.tts_source_whisper.state(("disabled",)) - self.tts_source_whisper.pack(side="left", padx=(0, 5)) - - self.tts_source_ai = ttk.Checkbutton(tts_source_frame, text="A", onvalue=True, offvalue=False, - command=lambda: self.on_tts_source_changed("ai")) - if self.tts_available: - self.tts_source_ai.state(("!alternate",)) - else: - self.tts_source_ai.state(("disabled",)) - self.tts_source_ai.pack(side="left", padx=(0, 5)) - - self.tts_source_translation = ttk.Checkbutton(tts_source_frame, text="T", onvalue=True, offvalue=False, - command=lambda: self.on_tts_source_changed("translation")) - if self.tts_available: - self.tts_source_translation.state(("!alternate",)) - else: - self.tts_source_translation.state(("disabled",)) - self.tts_source_translation.pack(side="left", padx=(0, 5)) - - # Track current TTS source - self.tts_source_active = "whisper" # Default to Whisper - - # Voice reference - tts_voice_frame = ttk.Frame(self.controls_frame) - tts_voice_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - ttk.Label(tts_voice_frame, text="Voice:").pack(side="left", padx=(0, 2)) - self.tts_voice_label = ttk.Label(tts_voice_frame, text="Default", foreground="gray", width=15, anchor="w") - self.tts_voice_label.pack(side="left", fill="x", expand=True, padx=(0, 5)) - self.tts_browse_button = ttk.Button(tts_voice_frame, text="Browse", width=8, command=self.browse_voice) - if not self.tts_available: - self.tts_browse_button.state(("disabled",)) - self.tts_browse_button.pack(side="left", padx=(0, 5)) - - self.tts_clear_button = ttk.Button(tts_voice_frame, text="Clear", width=6, command=self.clear_voice) - if not self.tts_available: - self.tts_clear_button.state(("disabled",)) - self.tts_clear_button.pack(side="left") - - # File output options - tts_output_frame = ttk.Frame(self.controls_frame) - tts_output_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - ttk.Label(tts_output_frame, text="Output:").pack(side="left", padx=(0, 2)) - self.tts_save_check = ttk.Checkbutton(tts_output_frame, text="Save to file", onvalue=True, offvalue=False) - if self.tts_available: - self.tts_save_check.state(("!alternate",)) - else: - self.tts_save_check.state(("disabled",)) - self.tts_save_check.pack(side="left", padx=(0, 10)) - - self.tts_format_combo = ttk.Combobox(tts_output_frame, values=["wav", "ogg"], state="readonly", width=5) - self.tts_format_combo.current(0) - if not self.tts_available: - self.tts_format_combo.state(("disabled",)) - self.tts_format_combo.pack(side="left") - - # TTS status - self.tts_status_label = ttk.Label(self.controls_frame, text="", foreground="blue", - wraplength=340, font=('TkDefaultFont', 8)) - self.tts_status_label.grid(row=row, column=0, sticky="ew", pady=(0, 5), padx=5) - row += 1 - - # === CONTROL BUTTON & LEVEL === - control_frame = ttk.Frame(self.controls_frame) - control_frame.grid(row=row, column=0, sticky="ew", pady=(0, 5)) - row += 1 - - self.control_button = ttk.Button(control_frame, text="Start", command=self.start, state="normal") - self.control_button.pack(side="top", fill="x", pady=(0, 5)) - - level_frame = ttk.Frame(control_frame) - level_frame.pack(side="top", fill="x") - ttk.Label(level_frame, text="Level:").pack(side="left", padx=(0, 5)) - self.level_bar = ttk.Progressbar(level_frame, length=100, mode='determinate', maximum=100) - self.level_bar.pack(side="left", fill="x", expand=True) - - # Auto-stop controls - autostop_frame = ttk.Frame(control_frame) - autostop_frame.pack(side="top", fill="x", pady=(5, 0)) - - self.autostop_check = ttk.Checkbutton(autostop_frame, text="Auto-stop after", onvalue=True, offvalue=False) - self.autostop_check.pack(side="left", padx=(0, 2)) - - self.autostop_spin = ttk.Spinbox(autostop_frame, from_=1, to=60, increment=1, state="normal", width=4) - self.autostop_spin.set(5) # Default 5 minutes - self.autostop_spin.pack(side="left", padx=(0, 2)) - - ttk.Label(autostop_frame, text="min of inactivity").pack(side="left") - - # === STATUS === - self.status_label = ttk.Label(self.controls_frame, text="", foreground="red", wraplength=340) - self.status_label.grid(row=row, column=0, sticky="ew", padx=5) - row += 1 - - # State variables - self.ready = [None] - self.error = [None] - self.level = [0] - self.autotype_error_shown = False - self.manual_trigger_requested = [False] # Flag for manual AI processing - - # TTS session tracking - self.tts_session_text = "" # Accumulate text for TTS - self.tts_session_id = None # Current session ID - - # Apply loaded text visibility state - if not self.text_visible: - # Hide text frame and update button for minimal mode - self.text_frame.grid_remove() - self.hide_text_button.config(text="Show Text ▶") - - # Load and apply TTS settings - if self.tts_available: - tts_enabled = self.settings.get("tts_enabled", False) - tts_save_file = self.settings.get("tts_save_file", False) - tts_source = self.settings.get("tts_source", "whisper") - - if tts_enabled: - self.tts_check.state(("selected",)) - if tts_save_file: - self.tts_save_check.state(("selected",)) - - # Restore TTS source selection - self.tts_source_active = tts_source - if tts_source == "whisper": - self.tts_source_whisper.state(("selected",)) - elif tts_source == "ai": - self.tts_source_ai.state(("selected",)) - elif tts_source == "translation": - self.tts_source_translation.state(("selected",)) - - # Load and apply AI settings - if self.ai_available: - ai_enabled = self.settings.get("ai_enabled", False) - if ai_enabled: - self.ai_check.state(("selected",)) - - # Load AI persona/task - ai_persona_index = self.settings.get("ai_persona_index", 0) - try: - if 0 <= ai_persona_index < len(self.ai_persona_combo.cget("values")): - self.ai_persona_combo.current(ai_persona_index) - except: - pass - - # Load AI translate checkbox - ai_translate = self.settings.get("ai_translate", False) - if ai_translate: - self.ai_translate_check.state(("selected",)) - - # Load AI translate-only checkbox - ai_translate_only = self.settings.get("ai_translate_only", False) - if ai_translate_only: - self.ai_translate_only_check.state(("selected",)) - # Trigger the mode changed handler to disable task controls - self.on_translate_mode_changed() - - # Load AI manual mode checkbox - ai_manual_mode = self.settings.get("ai_manual_mode", False) - if ai_manual_mode: - self.ai_manual_mode_check.state(("selected",)) - # Trigger the mode changed handler to enable/disable controls - self.on_trigger_mode_changed() - - # Load AI model selection - ai_model_index = self.settings.get("ai_model_index", 0) - try: - if 0 <= ai_model_index < len(self.ai_model_combo.cget("values")): - self.ai_model_combo.current(ai_model_index) - except: - pass - - # Load AI trigger mode - ai_trigger_mode = self.settings.get("ai_trigger_mode", "time") - try: - trigger_values = self.ai_trigger_combo.cget("values") - trigger_display = ai_trigger_mode.capitalize() - if trigger_display in trigger_values: - self.ai_trigger_combo.set(trigger_display) - except: - pass - - # Load AI interval (convert seconds to label) - ai_process_interval = self.settings.get("ai_process_interval", 20) - try: - # Find the label that matches this interval value - for label, value in self.ai_interval_values_map.items(): - if int(value) == ai_process_interval: - self.ai_interval_combo.set(label) - break - except: - pass - - # Load AI words - ai_process_words = self.settings.get("ai_process_words", 150) - try: - self.ai_words_spin.set(ai_process_words) - except: - pass - - # Load auto-stop settings - auto_stop_enabled = self.settings.get("auto_stop_enabled", False) - auto_stop_minutes = self.settings.get("auto_stop_minutes", 5) - if auto_stop_enabled: - self.autostop_check.state(("selected",)) - try: - self.autostop_spin.set(auto_stop_minutes) - except: - pass - - # Load translation language settings - try: - source_lang = self.settings.get("source_language", "auto") - target_lang = self.settings.get("target_language", "none") - - # Set source language - source_values = self.source_combo.cget("values") - if source_lang in source_values: - self.source_combo.set(source_lang) - - # Set target language - target_values = self.target_combo.cget("values") - if target_lang in target_values: - self.target_combo.set(target_lang) - except Exception as e: - print(f"Error loading language settings: {e}") - - # Set initial trigger field visibility based on trigger mode - if self.ai_available: - self.on_trigger_changed() - - # Set initial window geometry based on mode - if self.text_visible: - self.geometry("900x1000") # Full mode - else: - self.geometry("400x1000") # Minimal mode (default) - - # Save settings on window close - self.protocol("WM_DELETE_WINDOW", self.on_closing) - - def on_new_transcription(self, text): - """Called when NEW transcription text arrives. Auto-types if enabled.""" - # Accumulate for TTS if Whisper source is selected - if self.tts_available and "selected" in self.tts_check.state() and self.tts_source_active == "whisper": - self.tts_session_text += text + " " - - # Show waiting indicator if we're in Translation or AI mode - if self.autotype_mode_active == "Translation" and text: - self.status_label.config(text="⏳ Waiting for translation...", foreground="blue") - elif self.autotype_mode_active == "AI" and text: - self.status_label.config(text="⏳ Waiting for AI processing...", foreground="blue") - - # Auto-type Whisper output if that mode is selected - if self.autotype_mode_active == "Whisper" and text: - try: - import autotype - # Type immediately in a thread - def do_type(): - if not autotype.type_text(text, restore_clipboard=False): - if not self.autotype_error_shown: - self.autotype_error_shown = True - # Show error in status (thread-safe via after) - self.after(0, lambda: self.status_label.config( - text="Auto-type failed. Run: python autotype.py --check")) - threading.Thread(target=do_type, daemon=True).start() - except ImportError: - if not self.autotype_error_shown: - self.autotype_error_shown = True - self.status_label.config(text="autotype.py not found") - - def on_new_translation(self, text): - """Called when NEW translated/proofread text arrives. Auto-types if Translation or AI mode selected.""" - # Accumulate for TTS if Translation source is selected - if self.tts_available and "selected" in self.tts_check.state() and self.tts_source_active == "translation": - self.tts_session_text += text + " " - - # Auto-type Translation or AI output if those modes are selected - if self.autotype_mode_active in ("Translation", "AI") and text: - # Clear waiting message and show auto-typing indicator - mode_name = "translation" if self.autotype_mode_active == "Translation" else "AI output" - self.status_label.config(text=f"⌨ Auto-typing {mode_name}...", foreground="green") - - try: - import autotype - # Type immediately in a thread - def do_type(): - if not autotype.type_text(text, restore_clipboard=False): - if not self.autotype_error_shown: - self.autotype_error_shown = True - # Show error in status (thread-safe via after) - self.after(0, lambda: self.status_label.config( - text="Auto-type failed. Run: python autotype.py --check")) - else: - # Success - clear status after a moment - self.after(1000, lambda: self.status_label.config(text="")) - threading.Thread(target=do_type, daemon=True).start() - except ImportError: - if not self.autotype_error_shown: - self.autotype_error_shown = True - self.status_label.config(text="autotype.py not found") - - def update_ts_count(self): - """Update character and word count for Whisper output.""" - text = self.ts_text.get("1.0", "end-1c").strip() - char_count = len(text) - word_count = len(text.split()) if text else 0 - self.ts_count_label.config(text=f"{char_count} chars, {word_count} words") - # Update control panel button count (blue) - self.whisper_count_button.config(text=f"{char_count}c, {word_count}w") - - def update_pr_count(self): - """Update character and word count for Proofread output.""" - text = self.pr_text.get("1.0", "end-1c").strip() - char_count = len(text) - word_count = len(text.split()) if text else 0 - self.pr_count_label.config(text=f"{char_count} chars, {word_count} words") - # Update control panel button count (blue) - self.proofread_count_button.config(text=f"{char_count}c, {word_count}w") - - def update_tl_count(self): - """Update character and word count for Translation output.""" - text = self.tl_text.get("1.0", "end-1c").strip() - char_count = len(text) - word_count = len(text.split()) if text else 0 - self.tl_count_label.config(text=f"{char_count} chars, {word_count} words") - # Update control panel button count (blue) - self.translation_count_button.config(text=f"{char_count}c, {word_count}w") - - def on_new_proofread(self, text): - """Called when NEW proofread text arrives. Auto-types if AI mode selected.""" - # Accumulate for TTS if AI source is selected - if self.tts_available and "selected" in self.tts_check.state() and self.tts_source_active == "ai": - self.tts_session_text += text + " " - - # Auto-type proofread output if AI mode is selected - if self.autotype_mode_active == "AI" and text: - # Clear waiting message and show auto-typing indicator - self.status_label.config(text="⌨ Auto-typing AI proofread...", foreground="green") - - try: - import autotype - # Type immediately in a thread - def do_type(): - if not autotype.type_text(text, restore_clipboard=False): - if not self.autotype_error_shown: - self.autotype_error_shown = True - self.after(0, lambda: self.status_label.config( - text="Auto-type failed. Run: python autotype.py --check")) - else: - # Success - clear status after a moment - self.after(1000, lambda: self.status_label.config(text="")) - threading.Thread(target=do_type, daemon=True).start() - except ImportError: - if not self.autotype_error_shown: - self.autotype_error_shown = True - self.status_label.config(text="autotype.py not found") - - def copy_whisper_text(self): - """Copy Whisper output text to clipboard.""" - text = self.ts_text.get("1.0", "end-1c").strip() - if text: - self._copy_to_clipboard(text) - self.status_label.config(text="✓ Copied Whisper text to clipboard", foreground="green") - else: - self.status_label.config(text="No text to copy", foreground="orange") - - def cut_whisper_text(self): - """Cut Whisper output text (copy and clear).""" - text = self.ts_text.get("1.0", "end-1c").strip() - if text: - self._copy_to_clipboard(text) - self.ts_text.clear() - self.status_label.config(text="✓ Cut Whisper text to clipboard", foreground="green") - else: - self.status_label.config(text="No text to cut", foreground="orange") - - def copy_proofread_text(self): - """Copy Proofread output text to clipboard.""" - text = self.pr_text.get("1.0", "end-1c").strip() - if text: - self._copy_to_clipboard(text) - self.status_label.config(text="✓ Copied proofread text to clipboard", foreground="green") - else: - self.status_label.config(text="No text to copy", foreground="orange") - - def cut_proofread_text(self): - """Cut Proofread output text (copy and clear).""" - text = self.pr_text.get("1.0", "end-1c").strip() - if text: - self._copy_to_clipboard(text) - self.pr_text.clear() - self.status_label.config(text="✓ Cut proofread text to clipboard", foreground="green") - else: - self.status_label.config(text="No text to cut", foreground="orange") - - def copy_translation_text(self): - """Copy Translation output text to clipboard.""" - text = self.tl_text.get("1.0", "end-1c").strip() - if text: - self._copy_to_clipboard(text) - self.status_label.config(text="✓ Copied translation to clipboard", foreground="green") - else: - self.status_label.config(text="No text to copy", foreground="orange") - - def cut_translation_text(self): - """Cut Translation/Proofread output text (copy and clear).""" - text = self.tl_text.get("1.0", "end-1c").strip() - if text: - self._copy_to_clipboard(text) - self.tl_text.clear() - self.status_label.config(text="✓ Cut translation to clipboard", foreground="green") - else: - self.status_label.config(text="No text to cut", foreground="orange") - - def _copy_to_clipboard(self, text): - """Copy text to system clipboard using tkinter.""" - try: - self.clipboard_clear() - self.clipboard_append(text) - self.update() # Required for clipboard to persist - return True - except Exception as e: - self.status_label.config(text=f"Clipboard error: {e}", foreground="red") - return False - - def on_trigger_mode_changed(self): - """Handle Manual mode checkbox - enable/disable manual vs automatic triggers.""" - if not self.ai_available: - return - - manual_mode = "selected" in self.ai_manual_mode_check.state() - - if manual_mode: - # Manual mode: enable Process Now button, disable automatic triggers - self.ai_manual_button.state(("!disabled",)) - for widget in self.auto_trigger_widgets: - try: - widget.state(("disabled",)) - except: - widget.config(state="disabled") # For Label widgets - else: - # Automatic mode: disable Process Now button, enable automatic triggers - self.ai_manual_button.state(("disabled",)) - for widget in self.auto_trigger_widgets: - try: - widget.state(("!disabled",)) - except: - widget.config(state="normal") # For Label widgets - - def on_translate_mode_changed(self): - """Handle Translate Only checkbox - disable/enable task controls and auto-enable AI.""" - if not self.ai_available: - return - - translate_only = "selected" in self.ai_translate_only_check.state() - translate_output = "selected" in self.ai_translate_check.state() - - # Validate target language is set before allowing translation - target = self.target_combo.get() - if (translate_only or translate_output) and (target is None or target == "none"): - # Uncheck the option and show warning - if translate_only: - self.ai_translate_only_check.state(("!selected",)) - if translate_output: - self.ai_translate_check.state(("!selected",)) - self.status_label.config(text="⚠ Please select translation target language first", foreground="red") - return - - # Auto-enable AI if translation features are used - if translate_only or translate_output: - self.ai_check.state(("selected",)) - - if translate_only: - # Translate Only mode: disable task and translate output checkbox - self.ai_persona_combo.state(("disabled",)) - self.ai_translate_check.state(("disabled",)) - else: - # Normal mode: enable task and translate output checkbox - self.ai_persona_combo.state(("!disabled",)) - self.ai_translate_check.state(("!disabled",)) - - def manual_ai_trigger(self): - """Manual trigger for AI processing - process accumulated text immediately.""" - if not self.ready[0]: - # Not running, can't trigger - return - - # Set flag to request manual processing - self.manual_trigger_requested[0] = True - if self.debug_enabled: - print("[GUI] Manual AI processing requested", flush=True) - - def finalize_tts_session(self): - """Generate TTS audio from accumulated session text.""" - if not self.tts_session_text.strip() or not self.tts_available: - return - - # Check if we should save to file - save_to_file = "selected" in self.tts_save_check.state() - - if save_to_file and self.tts_session_id: - file_format = self.tts_format_combo.get() - filename = f"tts_session_{self.tts_session_id}" - - # Synthesize accumulated text to single file - self.tts_controller.synthesize_to_file( - text=self.tts_session_text.strip(), - output_filename=filename, - file_format=file_format, - async_mode=True - ) - - # Reset session - self.tts_session_text = "" - - def on_model_changed(self, event=None): - """Update VRAM label when model changes.""" - model = self.model_combo.get() - vram_info = MODEL_VRAM.get(model, "") - self.vram_label.config(text=vram_info) - - def on_target_changed(self, event=None): - """Handle target language changes - turn off Translate Output if target is none.""" - if not self.ai_available: - return - - target = self.target_combo.get() - if target == "none" or target is None: - # Turn off Translate Output checkbox if target is none - self.ai_translate_check.state(("!selected",)) - - def on_trigger_changed(self, event=None): - """Update visible controls based on trigger mode selection.""" - trigger_mode = self.ai_trigger_combo.get() - - if trigger_mode == "Time": - # Show time controls, hide word controls - self.ai_words_label.pack_forget() - self.ai_words_spin.pack_forget() - if not self.ai_interval_label.winfo_ismapped(): - self.ai_interval_label.pack(side="left", padx=(5, 2)) - self.ai_interval_combo.pack(side="left") - else: # Words - # Show word controls, hide time controls - self.ai_interval_label.pack_forget() - self.ai_interval_combo.pack_forget() - if not self.ai_words_label.winfo_ismapped(): - self.ai_words_label.pack(side="left", padx=(5, 2)) - self.ai_words_spin.pack(side="left") - - def browse_voice(self): - """Browse and select a reference voice audio file.""" - filename = filedialog.askopenfilename( - title="Select Reference Voice Audio", - filetypes=[ - ("Audio Files", "*.wav *.mp3 *.ogg *.flac *.m4a"), - ("All Files", "*.*") - ] - ) - if filename: - try: - self.tts_controller.set_reference_voice(filename) - # Show shortened filename - short_name = filename.split("/")[-1] - if len(short_name) > 20: - short_name = short_name[:17] + "..." - self.tts_voice_label.config(text=short_name, foreground="black") - self.tts_status_label.config(text=f"Voice loaded: {short_name}") - except Exception as e: - self.tts_status_label.config(text=f"Error loading voice: {e}", foreground="red") - - def clear_voice(self): - """Clear the reference voice and use default.""" - if self.tts_controller: - self.tts_controller.set_reference_voice(None) - self.tts_voice_label.config(text="Default", foreground="gray") - self.tts_status_label.config(text="Using default voice") - - def on_tts_progress(self, message: str): - """Called during TTS synthesis progress.""" - if not self.is_shutting_down: - self.after(0, lambda: self.tts_status_label.config(text=message, foreground="blue")) - - def on_tts_complete(self, filepath: str): - """Called when TTS synthesis completes.""" - if not self.is_shutting_down: - filename = filepath.split("/")[-1] - self.after(0, lambda: self.tts_status_label.config( - text=f"✓ Saved: {filename}", foreground="green")) - - def on_tts_error(self, error_msg: str): - """Called when TTS synthesis fails.""" - if not self.is_shutting_down: - self.after(0, lambda: self.tts_status_label.config( - text=f"TTS error: {error_msg}", foreground="red")) - - def on_tts_source_changed(self, source): - """Handle TTS source selection - make checkboxes mutually exclusive.""" - if not self.tts_available: - return - - # Update active source - self.tts_source_active = source - - # Uncheck all others - if source == "whisper": - self.tts_source_whisper.state(("selected",)) - self.tts_source_ai.state(("!selected",)) - self.tts_source_translation.state(("!selected",)) - elif source == "ai": - self.tts_source_whisper.state(("!selected",)) - self.tts_source_ai.state(("selected",)) - self.tts_source_translation.state(("!selected",)) - else: # translation - self.tts_source_whisper.state(("!selected",)) - self.tts_source_ai.state(("!selected",)) - self.tts_source_translation.state(("selected",)) - - def toggle_text_display(self): - """Toggle visibility of text panes.""" - if self.text_visible: - # MINIMAL MODE: Hide text frame - self.text_frame.grid_remove() - self.hide_text_button.config(text="Show Text ▶") - self.text_visible = False - - # Reconfigure grid: controls column expands to fill window - self.columnconfigure(0, weight=1, minsize=350) - self.columnconfigure(1, weight=0) # Text column won't expand - - # Adjust minimum size for minimal mode (never below 400x1000) - self.minsize(400, 1000) - self.maxsize(10000, 1000) # Limit max height to 1000px - - # Resize window to minimal width - self.geometry("400x1000") - else: - # FULL MODE: Show text frame - self.text_frame.grid() - self.hide_text_button.config(text="Hide Text ◀") - self.text_visible = True - - # Reconfigure grid: restore original layout - self.columnconfigure(0, weight=0, minsize=350) # Controls fixed - self.columnconfigure(1, weight=1) # Text column expands - - # Restore minimum size for full mode - self.minsize(900, 1000) - self.maxsize(10000, 1000) # Limit max height to 1000px - - # Resize window to show both columns (max height 1000px) - self.geometry("900x1000") - - # Save state to settings - self.settings.set("text_visible", self.text_visible) - self.settings.save() - - def on_closing(self): - """Save settings before closing the window.""" - # Set shutdown flag to prevent TTS thread crashes - self.is_shutting_down = True - - # Stop any ongoing transcription first - if hasattr(self, 'ready') and self.ready[0] is not None: - self.ready[0] = False - # Give threads a moment to notice the flag - self.after(100, self._continue_closing) - else: - self._continue_closing() - - def _continue_closing(self): - """Continue with cleanup after stopping transcription.""" - # Save text visibility (already saved in toggle, but ensure it's current) - self.settings.set("text_visible", self.text_visible) - - # Save TTS settings - if self.tts_available: - try: - tts_enabled = "selected" in self.tts_check.state() - tts_save_file = "selected" in self.tts_save_check.state() - self.settings.set("tts_enabled", tts_enabled) - self.settings.set("tts_save_file", tts_save_file) - self.settings.set("tts_source", self.tts_source_active) - except Exception as e: - print(f"Error saving TTS settings: {e}") - - # Save AI settings - if self.ai_available: - try: - ai_enabled = "selected" in self.ai_check.state() - self.settings.set("ai_enabled", ai_enabled) - - # Save AI translate checkbox - ai_translate = "selected" in self.ai_translate_check.state() - self.settings.set("ai_translate", ai_translate) - - # Save AI translate-only checkbox - ai_translate_only = "selected" in self.ai_translate_only_check.state() - self.settings.set("ai_translate_only", ai_translate_only) - - # Save AI manual mode checkbox - ai_manual_mode = "selected" in self.ai_manual_mode_check.state() - self.settings.set("ai_manual_mode", ai_manual_mode) - - # Save AI persona selection - persona_idx = self.ai_persona_combo.current() - self.settings.set("ai_persona_index", persona_idx) - - # Save AI model selection - ai_model_index = self.ai_model_combo.current() - self.settings.set("ai_model_index", ai_model_index) - - # Save AI trigger mode and interval/words - ai_trigger_mode = self.ai_trigger_combo.get().lower() - self.settings.set("ai_trigger_mode", ai_trigger_mode) - - # Save interval value (convert from label to seconds) - interval_label = self.ai_interval_combo.get() - ai_process_interval = int(self.ai_interval_values_map.get(interval_label, "20")) - self.settings.set("ai_process_interval", ai_process_interval) - - # Save words value - ai_process_words = int(self.ai_words_spin.get()) - self.settings.set("ai_process_words", ai_process_words) - except Exception as e: - print(f"Error saving AI settings: {e}") - - # Save translation language settings - try: - source_lang = self.source_combo.get() - target_lang = self.target_combo.get() - self.settings.set("source_language", source_lang) - self.settings.set("target_language", target_lang) - except Exception as e: - print(f"Error saving language settings: {e}") - - # Save auto-stop settings - try: - auto_stop_enabled = "selected" in self.autostop_check.state() - auto_stop_minutes = int(self.autostop_spin.get()) - self.settings.set("auto_stop_enabled", auto_stop_enabled) - self.settings.set("auto_stop_minutes", auto_stop_minutes) - except Exception as e: - print(f"Error saving auto-stop settings: {e}") - - # Persist to disk - self.settings.save() - - # Clean up TTS controller gracefully - if self.tts_controller is not None: - try: - # Clear callbacks to prevent accessing destroyed widgets - self.tts_controller.on_progress = None - self.tts_controller.on_complete = None - self.tts_controller.on_error = None - self.tts_controller = None - except Exception as e: - print(f"Error cleaning up TTS: {e}") - - # Close the window - self.destroy() - - def refresh_mics(self): - current = self.mic_combo.get() - self.mic_list = core.get_mic_names() - mic_display = ["(system default)"] + [name for idx, name in self.mic_list] - self.mic_combo.config(values=mic_display) - # Try to keep current selection - if current in mic_display: - self.mic_combo.set(current) - else: - self.mic_combo.current(0) - - def start(self): - # Validation: Check if target language is required but not set - target = self.target_combo.get() - - # Check if translation is needed - needs_translation = False - if self.ai_available: - translate_only = self.ai_translate_only_check.instate(("selected",)) - translate_output = self.ai_translate_check.instate(("selected",)) - ai_enabled = self.ai_check.instate(("selected",)) - - if translate_only or (ai_enabled and translate_output): - needs_translation = True - - # Validate target language - if needs_translation and (target is None or target == "none"): - self.status_label.config(text="⚠ Please select a target language for translation", foreground="red") - return - - self.ready[0] = False - self.error[0] = None - self.status_label.config(text="") - self.autotype_error_shown = False # Reset error flag - self.control_button.config(text="Starting...", command=None, state="disabled") - - # Start new TTS session - import time - self.tts_session_id = time.strftime("%Y%m%d_%H%M%S") - self.tts_session_text = "" - # Get mic index: use smart default or stored index - combo_idx = self.mic_combo.current() - if combo_idx == 0: - index = core.get_default_device_index() # Smart default (pipewire/pulse) - else: - # mic_list is [(device_index, name), ...], combo index 1 = mic_list[0] - index = self.mic_list[combo_idx - 1][0] - model = self.model_combo.get() - vad = self.vad_check.instate(("selected",)) - para_detect = self.para_check.instate(("selected",)) - self.autotype_mode_active = self.autotype_mode.get() # Capture autotype mode - memory = int(self.memory_spin.get()) - patience = float(self.patience_spin.get()) - timeout = float(self.timeout_spin.get()) - prompt = "" # Removed prompt entry - using custom tasks instead - source = None if self.source_combo.get() == "auto" else self.source_combo.get() - target = None if self.target_combo.get() == "none" else self.target_combo.get() - device = self.device_combo.get() - self.level[0] = 0 - - # Create AI processor if enabled - ai_processor = None - if self.ai_available and self.ai_check.instate(("selected",)): - try: - from ai_provider import AITextProcessor - - # Get selected model - model_idx = self.ai_model_combo.current() - models = self.ai_config.get_models() - selected_model_id = models[model_idx]['id'] - - # Get persona and translate settings - translate_only = self.ai_translate_only_check.instate(("selected",)) - - # Determine mode based on translate-only or task mode - if translate_only: - # Translate Only mode: direct 1:1 AI translation (no processing) - mode = "translate" - persona_name = "Translate Only" - else: - # Task mode: get persona and translate settings - persona_idx = self.ai_persona_combo.current() - persona_id = self.persona_ids[persona_idx] - translate_enabled = self.ai_translate_check.instate(("selected",)) - - # Map persona + translate to mode - if persona_id == "proofread": - # Built-in proofread persona - if translate_enabled and target is not None: - mode = "proofread_translate" - else: - mode = "proofread" - else: - # Custom persona - for now, treat as proofread - # TODO: Add custom persona support to ai_provider.py - if translate_enabled and target is not None: - mode = "proofread_translate" - else: - mode = "proofread" - - persona_name = self.ai_persona_combo.get() - - # Create AI processor - ai_processor = AITextProcessor( - config=self.ai_config, - model_id=selected_model_id, - mode=mode, - source_lang=source, - target_lang=target - ) - - # Display status - if translate_only: - self.status_label.config(text=f"AI: {models[model_idx]['name']} (Translate Only)", foreground="green") - else: - translate_status = " + Translate" if translate_enabled and target else "" - self.status_label.config(text=f"AI: {models[model_idx]['name']} ({persona_name}{translate_status})", foreground="green") - except Exception as e: - self.status_label.config(text=f"AI Error: {str(e)[:50]}", foreground="red") - print(f"Failed to initialize AI processor: {e}") - ai_processor = None - - # Get AI processing parameters - manual_mode = "selected" in self.ai_manual_mode_check.state() if self.ai_available else False - - if manual_mode: - # Manual mode: set trigger to "manual" to skip automatic processing - ai_trigger_mode = "manual" - ai_process_interval = 999999 # Large value (won't be used) - ai_process_words = None - if self.debug_enabled: - print(f"[GUI] AI Trigger Mode: Manual", flush=True) - else: - # Automatic mode: use configured trigger settings - if self.ai_available: - interval_label = self.ai_interval_combo.get() - ai_process_interval = int(self.ai_interval_values_map.get(interval_label, "20")) # seconds - else: - ai_process_interval = 20 # Default 20 seconds - ai_trigger_mode = self.ai_trigger_combo.get().lower() if self.ai_available else "time" - ai_process_words = int(self.ai_words_spin.get()) if self.ai_available and ai_trigger_mode == "words" else None - - # Debug logging - if self.debug_enabled: - if ai_trigger_mode == "time": - print(f"[GUI] AI Trigger Mode: Time, Interval: {ai_process_interval}s", flush=True) - else: - print(f"[GUI] AI Trigger Mode: Words, Word Count: {ai_process_words}", flush=True) - - # Get auto-stop parameters from GUI - auto_stop_enabled = "selected" in self.autostop_check.state() - auto_stop_minutes = int(self.autostop_spin.get()) - - # Check if we need to enable proofread window (for AI proofread+translate or proofread-only modes) - prres_queue = None - if ai_processor and ai_processor.mode == "proofread_translate": - prres_queue = self.pr_text.res_queue - # Enable proofread window and show buttons - if self.debug_enabled: - print(f"[GUI] Enabling proofread window (mode: {ai_processor.mode})", flush=True) - print(f"[GUI] prres_queue ID: {id(prres_queue)}", flush=True) - print(f"[GUI] self.pr_text.res_queue ID: {id(self.pr_text.res_queue)}", flush=True) - self.pr_text.config(state="normal", background="white") - self.ai_output_buttons_frame.grid() - elif ai_processor and ai_processor.mode == "proofread": - # Enable proofread window for proofread-only mode, show buttons - prres_queue = self.pr_text.res_queue # FIX: Set queue for proofread-only mode - if self.debug_enabled: - print(f"[GUI] Enabling proofread window (mode: {ai_processor.mode})", flush=True) - print(f"[GUI] prres_queue ID: {id(prres_queue)}", flush=True) - self.pr_text.config(state="normal", background="white") - self.ai_output_buttons_frame.grid() - else: - # Disable proofread window and hide buttons (keep visible but grayed) - if self.debug_enabled: - print(f"[GUI] Disabling proofread window (mode: {ai_processor.mode if ai_processor else 'None'})", flush=True) - self.pr_text.config(state="disabled", background="#f0f0f0") - self.ai_output_buttons_frame.grid_remove() - - threading.Thread(target=core.proc, args=(index, model, vad, memory, patience, timeout, prompt, source, target, self.ts_text.res_queue, self.tl_text.res_queue, self.ready, device, self.error, self.level, para_detect), kwargs={'ai_processor': ai_processor, 'ai_process_interval': ai_process_interval, 'ai_process_words': ai_process_words, 'ai_trigger_mode': ai_trigger_mode, 'prres_queue': prres_queue, 'auto_stop_enabled': auto_stop_enabled, 'auto_stop_minutes': auto_stop_minutes, 'manual_trigger': self.manual_trigger_requested}, daemon=True).start() - self.starting() - self.update_level() - - def lock_ui_controls(self): - """Lock UI controls that shouldn't be changed while running.""" - # Lock model settings - self.model_combo.state(("disabled",)) - self.device_combo.state(("disabled",)) - self.autotype_mode.state(("disabled",)) - - # Lock translation language dropdowns - self.source_combo.state(("disabled",)) - self.target_combo.state(("disabled",)) - - # Lock AI settings - if self.ai_available: - # Get current AI state - ai_enabled = "selected" in self.ai_check.state() - - # Lock Enable AI checkbox - self.ai_check.state(("disabled",)) - - # Lock AI controls - self.ai_translate_only_check.state(("disabled",)) - self.ai_translate_check.state(("disabled",)) - self.ai_persona_combo.state(("disabled",)) - self.ai_model_combo.state(("disabled",)) - self.ai_manual_mode_check.state(("disabled",)) - - # Lock trigger controls - self.ai_trigger_combo.state(("disabled",)) - self.ai_interval_combo.state(("disabled",)) - - # Lock TTS controls - if self.tts_available: - # Lock Enable TTS checkbox - self.tts_check.state(("disabled",)) - - # Lock all TTS source checkboxes - self.tts_source_whisper.state(("disabled",)) - self.tts_source_ai.state(("disabled",)) - self.tts_source_translation.state(("disabled",)) - - # Lock TTS output controls - self.tts_save_check.state(("disabled",)) - self.tts_format_combo.state(("disabled",)) - self.tts_browse_button.state(("disabled",)) - self.tts_clear_button.state(("disabled",)) - - def unlock_ui_controls(self): - """Unlock UI controls after stopping.""" - # Unlock model settings - self.model_combo.state(("!disabled",)) - - # Explicitly remove disabled state before setting readonly - self.device_combo.state(("!disabled",)) - self.device_combo.state(("readonly",)) - - self.autotype_mode.state(("!disabled",)) - self.autotype_mode.state(("readonly",)) - - # Unlock translation language dropdowns - self.source_combo.state(("!disabled",)) - self.source_combo.state(("readonly",)) - - self.target_combo.state(("!disabled",)) - self.target_combo.state(("readonly",)) - - # Unlock AI settings - if self.ai_available: - # Unlock Enable AI checkbox - self.ai_check.state(("!disabled",)) - - # Re-enable based on current state - translate_only = "selected" in self.ai_translate_only_check.state() - self.ai_translate_only_check.state(("!disabled",)) - - # If Translate Only is selected, keep Translate Output disabled - if translate_only: - self.ai_translate_check.state(("disabled",)) - else: - self.ai_translate_check.state(("!disabled",)) - - self.ai_manual_mode_check.state(("!disabled",)) - - # Re-apply translate-only logic for task combo - if translate_only: - self.ai_persona_combo.state(("disabled",)) - else: - self.ai_persona_combo.state(("!disabled",)) - - self.ai_model_combo.state(("!disabled",)) - - # Unlock trigger controls - self.ai_trigger_combo.state(("!disabled",)) - self.ai_interval_combo.state(("!disabled",)) - - # Unlock TTS controls - if self.tts_available: - # Unlock Enable TTS checkbox - self.tts_check.state(("!disabled",)) - - # Unlock all TTS source checkboxes - self.tts_source_whisper.state(("!disabled",)) - self.tts_source_ai.state(("!disabled",)) - self.tts_source_translation.state(("!disabled",)) - - # Unlock TTS output controls - self.tts_save_check.state(("!disabled",)) - self.tts_format_combo.state(("!disabled",)) - self.tts_browse_button.state(("!disabled",)) - self.tts_clear_button.state(("!disabled",)) - - def starting(self): - if self.ready[0] is True: - self.control_button.config(text="Stop", command=self.stop, state="normal") - # Lock UI controls when started - self.lock_ui_controls() - return - if self.ready[0] is None: - if self.error[0]: - self.status_label.config(text=f"Error: {self.error[0]}") - self.control_button.config(text="Start", command=self.start, state="normal") - return - self.after(100, self.starting) - - def stop(self): - # Check if already stopped (auto-stop case) - if self.ready[0] is None: - # Already stopped - just update UI immediately - self.autotype_mode_active = "Off" - self.control_button.config(text="Start", command=self.start, state="normal") - self.level_bar['value'] = 0 - self.unlock_ui_controls() - self.finalize_tts_session() - return - - # Normal stop - signal core thread to stop - self.autotype_mode_active = "Off" # Disable autotype when stopping - self.ready[0] = False - self.control_button.config(text="Stopping...", command=None, state="disabled") - self.stopping() - - def stopping(self): - if self.ready[0] is None: - self.control_button.config(text="Start", command=self.start, state="normal") - self.level_bar['value'] = 0 - - # Unlock UI controls when stopped - self.unlock_ui_controls() - - # Finalize TTS session when stopping completes - self.finalize_tts_session() - return - self.after(100, self.stopping) - - def update_level(self): - if self.ready[0] is None: - self.level_bar['value'] = 0 - # Check if we need to trigger stopping cleanup (auto-stop case) - # If button shows "Stop", it means auto-stop happened and we need to update UI - if self.control_button['text'] == "Stop": - # Auto-stop detected - trigger stopping cleanup - self.autotype_mode_active = "Off" # Disable autotype - self.control_button.config(text="Start", command=self.start, state="normal") - self.unlock_ui_controls() - self.finalize_tts_session() - return - # Update level bar with current audio level - self.level_bar['value'] = min(100, self.level[0]) - self.after(50, self.update_level) - - -if __name__ == "__main__": - try: - App().mainloop() - except KeyboardInterrupt: - # Graceful exit on Ctrl+C - pass @@ -1,135 +0,0 @@ -import tkinter as tk -import tkinter.ttk as ttk - -# Model VRAM estimates (based on faster-whisper benchmarks) -MODEL_VRAM = { - "tiny": "~1 GB VRAM", - "base": "~1.5 GB VRAM", - "small": "~2 GB VRAM", - "medium": "~3 GB VRAM", - "large-v1": "~4.5 GB VRAM (fp16) / ~3 GB (int8)", - "large-v2": "~4.5 GB VRAM (fp16) / ~3 GB (int8)", - "large-v3": "~4.5 GB VRAM (fp16) / ~3 GB (int8)", - "large": "~4.5 GB VRAM (fp16) / ~3 GB (int8)", -} - -class HelpDialog: - """Help dialog with detailed information.""" - - # Track open dialogs for toggle behavior - _open_dialogs = {} - - # Compact help text for each section - HELP_TEXT = { - "model": """Model: Whisper model size (tiny→large-v3, larger=more accurate) - -VAD: Voice Activity Detection (filters silence/noise) - -¶: Adaptive paragraph detection (auto line breaks by pauses) - -⌨: Auto-type mode selector - • Off - No auto-typing - • Whisper - Type raw transcription immediately - • Translation - Type Google Translate output (1-2 sec delay) - • AI - Type AI-processed output (longer delay based on trigger) - -Dev: Inference device (cuda=GPU, cpu=CPU, auto=best) - -Mem: Context segments 1-10 (higher=better context, slower) - -Pat: Patience seconds (wait time before finalizing segment) - -Time: Translation timeout seconds""", - - "translate": """Source: Source language (auto=detect, or select specific) - -Target: Target language (none=disabled, or select for Google Translate) - -Note: AI Processing overrides Google Translate when enabled.""", - - "ai": """Enable AI: Intelligent proofreading and translation - -Mode: Proofread | Translate | Proofread+Translate - -Model: AI model selection (larger=more capable, higher cost) - -Trigger: Time (every N min) | Words (every N words) - -Setup: Add OPENROUTER_API_KEY to .env -See AI_SETUP.md for details.""", - - "tts": """Enable TTS: Convert text to speech - -Voice: Browse=upload ref audio for cloning | Clear=default - -Save File: Auto-save to tts_output/ with timestamp - -Format: WAV (lossless) | OGG (compressed) - -Setup: See INSTALL_TTS.md""" - } - - @staticmethod - def show(parent, section): - """Show help dialog for a section with toggle behavior.""" - if section not in HelpDialog.HELP_TEXT: - return - - # Toggle: if dialog is already open for this section, close it - if section in HelpDialog._open_dialogs: - try: - HelpDialog._open_dialogs[section].destroy() - del HelpDialog._open_dialogs[section] - except: - pass - return - - # Create new dialog window - dialog = tk.Toplevel(parent) - dialog.title(f"Help - {section.title()}") - dialog.geometry("450x280") - dialog.resizable(False, False) - - # Create text widget with scrollbar - text_frame = ttk.Frame(dialog) - text_frame.pack(fill="both", expand=True, padx=10, pady=10) - - scrollbar = ttk.Scrollbar(text_frame) - scrollbar.pack(side="right", fill="y") - - text_widget = tk.Text(text_frame, wrap="word", font=('TkDefaultFont', 9), - yscrollcommand=scrollbar.set, padx=8, pady=8) - text_widget.pack(side="left", fill="both", expand=True) - scrollbar.config(command=text_widget.yview) - - # Insert help text - text_widget.insert("1.0", HelpDialog.HELP_TEXT[section]) - text_widget.config(state="disabled") # Read-only - - # Track dialog - HelpDialog._open_dialogs[section] = dialog - - # Click outside to close functionality - def on_focus_out(event): - # Close dialog when clicking outside - try: - if dialog.winfo_exists(): - dialog.destroy() - if section in HelpDialog._open_dialogs: - del HelpDialog._open_dialogs[section] - except: - pass - - # Cleanup on close - def on_close(): - try: - if section in HelpDialog._open_dialogs: - del HelpDialog._open_dialogs[section] - dialog.destroy() - except: - pass - - dialog.protocol("WM_DELETE_WINDOW", on_close) - - # Bind focus out after a delay (to avoid immediate close on creation) - dialog.after(200, lambda: dialog.bind("<FocusOut>", on_focus_out)) @@ -1,99 +0,0 @@ -import tkinter as tk -from cmque import PairDeque, Queue -from debug import debug_print - -class Text(tk.Text): - def __init__(self, master, on_new_text=None, on_text_changed=None): - super().__init__(master) - self.res_queue = Queue(PairDeque()) - self.on_new_text = on_new_text # Callback for NEW text only - self.on_text_changed = on_text_changed # Callback when any text changes - self.tag_config("done", foreground="black") - self.tag_config("curr", foreground="blue", underline=True) - self.insert("end", " ", "done") - self.record = self.index("end-1c") - self.prev_done = "" # Track what we've already processed - self.see("end") - # Keep text editable! No state="disabled" - self.poll() - - def poll(self): - text_changed = False - queue_name = None - # Identify which Text widget this is for debug purposes - try: - if hasattr(self, 'winfo_parent'): - parent = self.nametowidget(self.winfo_parent()) - if hasattr(parent, 'winfo_parent'): - grandparent = parent.nametowidget(parent.winfo_parent()) - # Try to determine if this is the proofread text widget - if 'pr_text' in str(self): - queue_name = "PR_QUEUE" - elif 'tl_text' in str(self): - queue_name = "TL_QUEUE" - elif 'ts_text' in str(self): - queue_name = "TS_QUEUE" - except: - pass - - while self.res_queue: - # Temporarily enable widget if disabled (for programmatic updates) - was_disabled = str(self.cget("state")) == "disabled" - if was_disabled: - self.config(state="normal") - - if res := self.res_queue.get(): - done, curr = res - # Calculate NEW text (what we haven't processed yet) - new_text = "" - if len(done) > len(self.prev_done): - new_text = done[len(self.prev_done):] - self.prev_done = done - - if queue_name: - debug_print(f"[TEXT-{queue_name}] Received: done={done[:50]}... curr={curr[:50]}...", flush=True) - if new_text: - debug_print(f"[TEXT-{queue_name}] NEW text: {new_text[:100]}...", flush=True) - - # Update display - self.delete(self.record, "end") - self.insert("end", done, "done") - self.record = self.index("end-1c") - self.insert("end", curr, "curr") - self.see("end") - text_changed = True - - # Fire callback with only NEW text - if new_text and self.on_new_text: - self.on_new_text(new_text) - else: - # Stop signal - finalize current line - done = self.get(self.record, "end-1c") - self.delete(self.record, "end") - self.insert("end", done, "done") - self.insert("end", "\n", "done") - self.insert("end", " ", "done") - self.record = self.index("end-1c") - self.prev_done = "" # Reset for next segment - self.see("end") - text_changed = True - - # Restore disabled state if it was disabled - if was_disabled: - self.config(state="disabled") - - # Fire text changed callback if text was modified - if text_changed and self.on_text_changed: - self.on_text_changed() - - self.after(100, self.poll) - - def clear(self): - """Clear all text and reset state.""" - self.delete("1.0", "end") - self.insert("end", " ", "done") - self.record = self.index("end-1c") - self.prev_done = "" - # Fire text changed callback after clearing - if self.on_text_changed: - self.on_text_changed() @@ -1,21 +1,36 @@ #!/usr/bin/env python3 - import argparse import curses import threading import core from cmque import PairDeque, Queue -from transcript_logger import TranscriptLogger +from session_logger import SessionLogger + +# AI modules (optional) +try: + from ai_config import load_ai_config + from ai_provider import AITextProcessor + AI_AVAILABLE = True +except ImportError: + AI_AVAILABLE = False + +# Voice command modules (optional) +try: + from commands_config import load_voice_commands_config + from command_detector import CommandDetector + from command_executor import CommandExecutor + VOICE_COMMANDS_AVAILABLE = True +except ImportError: + VOICE_COMMANDS_AVAILABLE = False class Pad: - def __init__(self, h, w, t, l, logger=None): + def __init__(self, h, w, t, l): self.pad = curses.newpad(h * 2, w) self.h, self.w, self.t, self.l = h, w, t, l self.res_queue = Queue(PairDeque()) - self.logger = logger self.add_done("> ") self.refresh() @@ -53,15 +68,21 @@ class Pad: self.pad.move(y - t, x) self.y, self.x = self.pad.getyx() self.last = "" - # Log completed text (skip prompts) - if self.logger and done and done.strip() and not done.strip().startswith('>'): - self.logger.log_text(done) - def update(self): + def update(self, command_detector=None, command_executor=None): while self.res_queue: res = self.res_queue.get() if res is not None: done, curr = res + # Voice command detection on finalized text + if command_detector and command_executor and done: + result = command_detector.check(done) + if result is not None: + replacement = command_executor.execute(result) + done = replacement if replacement is not None else "" + # Suppress curr if it looks like a pending command + if curr and command_detector.check(curr) is not None: + curr = "" self.load_pos() self.add_done(done) self.add_curr(curr) @@ -74,9 +95,123 @@ class Pad: self.refresh() -def show(mic, model, vad, memory, patience, timeout, prompt, source, target, device, para_detect=True, para_threshold_std=1.5, para_min_pause=0.8, para_max_chars=500, para_max_words=100, enable_logging=True): - # Create transcript logger - logger = TranscriptLogger() if enable_logging else None +def _draw_borders(stdscr, t, b, l, r, dividers): + """Draw box borders and vertical dividers.""" + stdscr.hline(t, l + 1, curses.ACS_HLINE, r - l - 1) + stdscr.hline(b, l + 1, curses.ACS_HLINE, r - l - 1) + stdscr.vline(t + 1, l, curses.ACS_VLINE, b - t - 1) + stdscr.vline(t + 1, r, curses.ACS_VLINE, b - t - 1) + stdscr.addch(t, l, curses.ACS_ULCORNER) + stdscr.addch(b, l, curses.ACS_LLCORNER) + stdscr.addch(t, r, curses.ACS_URCORNER) + stdscr.addch(b, r, curses.ACS_LRCORNER) + for d in dividers: + stdscr.vline(t + 1, d, curses.ACS_VLINE, b - t - 1) + stdscr.addch(t, d, curses.ACS_TTEE) + stdscr.addch(b, d, curses.ACS_BTEE) + + +def _create_ai_processor(ai_persona, ai_model_index, source, target): + """Create AI processor if AI modules are available and configured.""" + if not AI_AVAILABLE: + return None + try: + ai_config = load_ai_config() + if not ai_config: + return None + + # Get selected model + models = ai_config.get_models() + if ai_model_index >= len(models): + ai_model_index = 0 + selected_model_id = models[ai_model_index]['id'] + + # Determine mode and persona + persona_id = ai_persona or "proofread" + personas = ai_config.get_personas() + persona = None + for p in personas: + if p['id'] == persona_id: + persona = p + break + if persona is None: + persona_id = "proofread" + + if persona_id == "proofread": + if target: + mode = "proofread_translate" + else: + mode = "proofread" + else: + mode = "custom" + + processor = AITextProcessor( + config=ai_config, + model_id=selected_model_id, + mode=mode, + source_lang=source, + target_lang=target, + persona_id=persona_id if mode == "custom" else None + ) + return processor + except Exception as e: + print(f"[WARN] AI init failed: {e}", flush=True) + return None + + +def _init_voice_commands(source_language): + """Initialize voice command detector and executor.""" + if not VOICE_COMMANDS_AVAILABLE: + return None, None + try: + config = load_voice_commands_config() + if config: + lang = source_language if source_language else None + detector = CommandDetector(config, language=lang) + executor = CommandExecutor() + return detector, executor + except Exception: + pass + return None, None + + +def show(mic, model, vad, memory, patience, timeout, prompt, source, target, + device, para_detect=True, para_threshold_std=1.5, para_min_pause=0.8, + para_max_chars=500, para_max_words=100, enable_logging=True, + ai_enabled=False, ai_persona=None, ai_model_index=0, + ai_trigger_mode="time", ai_process_interval=20, ai_process_words=150, + voice_commands=False, auto_stop_minutes=0, autotype_mode="Off"): + + # Session logger (JSONL) + session_logger = SessionLogger(log_dir="logs") if enable_logging else None + + # AI processor + ai_processor = None + if ai_enabled: + ai_processor = _create_ai_processor(ai_persona, ai_model_index, source, target) + if ai_processor: + print(f"[INFO] AI: mode={ai_processor.mode}, persona={ai_persona}", flush=True) + else: + print("[WARN] AI enabled but processor could not be created", flush=True) + + # Voice commands + command_detector, command_executor = (None, None) + if voice_commands: + command_detector, command_executor = _init_voice_commands(source) + + # Autotype + autotype_fn = None + if autotype_mode != "Off": + try: + import autotype as _autotype_mod + autotype_fn = _autotype_mod.type_text + except ImportError: + pass + + # Determine layout: how many panes do we need? + has_ai_pane = ai_processor is not None and ai_processor.mode in ("proofread", "proofread_translate", "custom") + has_tl_pane = target is not None + pane_count = 1 + int(has_ai_pane) + int(has_tl_pane) stdscr = curses.initscr() curses.setupterm() @@ -89,115 +224,253 @@ def show(mic, model, vad, memory, patience, timeout, prompt, source, target, dev b = h - 1 l = 0 r = w - 2 - m = w // 2 - 1 - stdscr.hline(t, l + 1, curses.ACS_HLINE, r - l - 1) - stdscr.hline(b, l + 1, curses.ACS_HLINE, r - l - 1) - stdscr.vline(t + 1, l, curses.ACS_VLINE, b - t - 1) - stdscr.vline(t + 1, r, curses.ACS_VLINE, b - t - 1) - stdscr.vline(t + 1, m, curses.ACS_VLINE, b - t - 1) - stdscr.addch(t, l, curses.ACS_ULCORNER) - stdscr.addch(b, l, curses.ACS_LLCORNER) - stdscr.addch(t, r, curses.ACS_URCORNER) - stdscr.addch(b, r, curses.ACS_LRCORNER) - stdscr.addch(t, m, curses.ACS_TTEE) - stdscr.addch(b, m, curses.ACS_BTEE) - ts_win = Pad(b - t - 1, m - l - 3, t + 1, l + 2, logger=logger) - tl_win = Pad(b - t - 1, r - m - 3, t + 1, m + 2) + + # Compute column boundaries + if pane_count == 1: + dividers = [] + cols = [(l, r)] + elif pane_count == 2: + m = w // 2 - 1 + dividers = [m] + cols = [(l, m), (m, r)] + else: + m1 = w // 3 - 1 + m2 = 2 * w // 3 - 1 + dividers = [m1, m2] + cols = [(l, m1), (m1, m2), (m2, r)] + + _draw_borders(stdscr, t, b, l, r, dividers) + + # Create pads for each pane + pane_idx = 0 + cl, cr = cols[pane_idx] + ts_win = Pad(b - t - 1, cr - cl - 3, t + 1, cl + 2) + pane_idx += 1 + + pr_win = None + if has_ai_pane: + cl, cr = cols[pane_idx] + pr_win = Pad(b - t - 1, cr - cl - 3, t + 1, cl + 2) + pane_idx += 1 + + tl_win = None + if has_tl_pane: + cl, cr = cols[pane_idx] + tl_win = Pad(b - t - 1, cr - cl - 3, t + 1, cl + 2) + + # Queues + ts_queue = ts_win.res_queue + tl_queue = tl_win.res_queue if tl_win else Queue(PairDeque()) + pr_queue = pr_win.res_queue if pr_win else None + + # Determine whether to use Google Translate + use_google_translate = ai_processor is None + ready = [None] error = [None] - level = [0] # Audio level (not displayed in TUI for now) - instr = " <Space> Start/Stop <Q> Quit <L> List logs" + level = [0] + manual_trigger = [False] + auto_stop_enabled = auto_stop_minutes > 0 + + # Build key help + keys = ["<Space> Start/Stop", "<Q> Quit"] + if ai_processor and ai_trigger_mode == "manual": + keys.append("<A> AI Process") + instr = " " + " ".join(f"<{k.split('>')[0][1:]}> {k.split('> ')[1]}" if '>' in k else k for k in keys) + instr = " " + " ".join(keys) + state = "Stopped" - log_file = None + log_request_id = None + while True: status = state if error[0] and state == "Stopped": status = f"Error: {error[0][:40]}" - elif log_file and state.startswith("Started"): - # Show log file being written + elif log_request_id and state.startswith("Started"): status = f"{state} (Logging)" - stdscr.addstr(0, l, status + " " * (r - l + 1 - len(status) - len(instr)) + instr) + + # Truncate status line to fit + status_line = status + " " * max(0, r - l + 1 - len(status) - len(instr)) + instr + stdscr.addstr(0, l, status_line[:r - l + 1]) stdscr.refresh() - ts_win.update() - tl_win.update() + + # Update panes - voice commands only on whisper pane + ts_win.update(command_detector, command_executor) + if pr_win: + pr_win.update() + if tl_win: + tl_win.update() + key = stdscr.getch() + if key == ord("q") or key == ord("Q"): - # End logging session if active - if logger and log_file: - logger.end_session() - log_file = None + # Finalize logging + if session_logger and log_request_id: + session_logger.finalize_session("manual") + log_request_id = None break - elif key == ord("l") or key == ord("L"): - # Show recent log files - if logger: - curses.endwin() - print("\n" + logger.format_log_list()) - print("\nPress Enter to continue...") - input() - stdscr = curses.initscr() - curses.setupterm() - curses.curs_set(0) - curses.noecho() - stdscr.clear() - stdscr.timeout(100) + + elif (key == ord("a") or key == ord("A")) and ai_processor and ai_trigger_mode == "manual": + manual_trigger[0] = True + elif state.startswith("Stopped"): if key == ord(" "): ready[0] = False error[0] = None - # Start logging session - if logger: - log_file = logger.start_session() - # Get mic index: smart default if not specified + + # Start session logging + if session_logger: + config = { + "model": model, "vad_enabled": vad, + "para_detect_enabled": para_detect, "device": device, + "source_language": source or "auto", + "target_language": target or "none", + "ai_enabled": ai_enabled, + "voice_commands": voice_commands, + "auto_stop_minutes": auto_stop_minutes, + "autotype_mode": autotype_mode, + } + if ai_processor: + config["ai_persona"] = ai_persona + config["ai_trigger_mode"] = ai_trigger_mode + log_request_id = session_logger.start_session(config) + + # Get mic index mic_index = core.get_mic_index(mic) if mic else core.get_default_device_index() - threading.Thread(target=core.proc, args=(mic_index, model, vad, memory, patience, timeout, prompt, source, target, ts_win.res_queue, tl_win.res_queue, ready, device, error, level, para_detect, para_threshold_std, para_min_pause, para_max_chars, para_max_words), daemon=True).start() + + # core.proc kwargs + kwargs = { + 'ai_processor': ai_processor, + 'ai_process_interval': ai_process_interval, + 'ai_process_words': ai_process_words if ai_trigger_mode == "words" else None, + 'ai_trigger_mode': "manual" if (ai_processor and ai_trigger_mode == "manual") else ai_trigger_mode, + 'prres_queue': pr_queue, + 'auto_stop_enabled': auto_stop_enabled, + 'auto_stop_minutes': auto_stop_minutes, + 'manual_trigger': manual_trigger, + 'use_google_translate': use_google_translate, + } + + threading.Thread( + target=core.proc, + args=(mic_index, model, vad, memory, patience, timeout, + prompt, source, target, ts_queue, tl_queue, ready, + device, error, level, para_detect, para_threshold_std, + para_min_pause, para_max_chars, para_max_words), + kwargs=kwargs, + daemon=True + ).start() state = "Starting..." + elif state.startswith("Started"): if key == ord(" "): ready[0] = False state = "Stopping..." + elif state.startswith("Stopping..."): if ready[0] is None: - # End logging session - if logger and log_file: - saved_file = logger.end_session() - log_file = None + if session_logger and log_request_id: + session_logger.finalize_session("manual") + log_request_id = None state = "Stopped" + elif state.startswith("Starting..."): if ready[0] is True: state = "Started" if ready[0] is None: state = "Stopped" + curses.endwin() def main(): - parser = argparse.ArgumentParser(description="Transcribe and translate speech in real-time.") + parser = argparse.ArgumentParser( + description="Transcribe and translate speech in real-time.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +keyboard controls: + Space Start/Stop recording + Q Quit + A Trigger AI processing (manual mode only) + +examples: + %(prog)s # Basic transcription + %(prog)s --target es # Transcribe and translate to Spanish + %(prog)s --ai --ai-persona proofread # AI proofreading + %(prog)s --voice-commands # Enable voice commands + %(prog)s --auto-stop 10 # Auto-stop after 10 min silence +""" + ) + + # Core transcription parser.add_argument("--mic", type=str, default=None, help="microphone device name") - parser.add_argument("--model", type=str, choices=core.models, default="large-v3", help="size of the model to use") + parser.add_argument("--model", type=str, choices=core.models, default="large-v3", help="Whisper model size") parser.add_argument("--vad", action="store_true", help="enable voice activity detection") - parser.add_argument("--memory", type=int, default=3, help="maximum number of previous segments to be used as prompt for audio in the transcribing window") - parser.add_argument("--patience", type=float, default=5.0, help="minimum time to wait for subsequent speech before move a completed segment out of the transcribing window") + parser.add_argument("--memory", type=int, default=3, help="number of previous segments for prompt context") + parser.add_argument("--patience", type=float, default=5.0, help="seconds to wait before finalizing a segment") parser.add_argument("--timeout", type=float, default=5.0, help="timeout for the translation service") - parser.add_argument("--prompt", type=str, default="", help="initial prompt for the first segment of each paragraph") - parser.add_argument("--source", type=str, default=None, choices=core.sources, help="source language for translation, auto-detect if not specified") - parser.add_argument("--target", type=str, default=None, choices=core.targets, help="target language for translation, no translation if not specified") - parser.add_argument("--device", type=str, default="cuda", choices=core.devices, help="device to use for inference (cpu, cuda, or auto)") - # Paragraph detection arguments - parser.add_argument("--no-para", action="store_true", help="disable adaptive paragraph detection") - parser.add_argument("--para-threshold", type=float, default=1.5, help="std deviations above mean pause for paragraph break (default: 1.5)") - parser.add_argument("--para-min-pause", type=float, default=0.8, help="minimum pause duration to consider for break (default: 0.8s)") - parser.add_argument("--para-max-chars", type=int, default=500, help="max characters per paragraph (default: 500)") - parser.add_argument("--para-max-words", type=int, default=100, help="max words per paragraph (default: 100)") - # Logging arguments - parser.add_argument("--no-log", action="store_true", help="disable transcript logging to log_output/") + parser.add_argument("--prompt", type=str, default="", help="initial prompt for transcription") + parser.add_argument("--source", type=str, default=None, choices=core.sources, help="source language (auto-detect if omitted)") + parser.add_argument("--target", type=str, default=None, choices=core.targets, help="target language for translation") + parser.add_argument("--device", type=str, default="cuda", choices=core.devices, help="inference device (cpu, cuda, auto)") + + # Paragraph detection + para = parser.add_argument_group("paragraph detection") + para.add_argument("--no-para", action="store_true", help="disable adaptive paragraph detection") + para.add_argument("--para-threshold", type=float, default=1.5, help="std devs above mean for paragraph break") + para.add_argument("--para-min-pause", type=float, default=0.8, help="minimum pause for paragraph break (seconds)") + para.add_argument("--para-max-chars", type=int, default=500, help="max characters per paragraph") + para.add_argument("--para-max-words", type=int, default=100, help="max words per paragraph") + + # AI processing + ai = parser.add_argument_group("AI processing") + ai.add_argument("--ai", action="store_true", help="enable AI text processing") + ai.add_argument("--ai-persona", type=str, default="proofread", help="AI persona id (proofread, qa, or custom id)") + ai.add_argument("--ai-model", type=int, default=0, help="AI model index from ai_config.yaml") + ai.add_argument("--ai-trigger", type=str, default="time", choices=["time", "words", "manual"], help="AI trigger mode") + ai.add_argument("--ai-interval", type=int, default=20, help="seconds between AI triggers (time mode)") + ai.add_argument("--ai-words", type=int, default=150, help="word count between AI triggers (words mode)") + + # Voice commands + vc = parser.add_argument_group("voice commands") + vc.add_argument("--voice-commands", action="store_true", help="enable voice command detection") + + # Auto-stop + parser.add_argument("--auto-stop", type=int, default=0, metavar="MINUTES", help="auto-stop after N minutes of silence (0=disabled)") + + # Autotype + parser.add_argument("--autotype", type=str, default="Off", choices=["Off", "Whisper", "Translation", "AI"], help="auto-type output to active window") + + # Logging + parser.add_argument("--no-log", action="store_true", help="disable session logging") + args = parser.parse_args() - show(args.mic, args.model, args.vad, args.memory, args.patience, args.timeout, args.prompt, args.source, args.target, args.device, - not args.no_para, args.para_threshold, args.para_min_pause, args.para_max_chars, args.para_max_words, not args.no_log) + + show( + mic=args.mic, model=args.model, vad=args.vad, + memory=args.memory, patience=args.patience, timeout=args.timeout, + prompt=args.prompt, source=args.source, target=args.target, + device=args.device, + para_detect=not args.no_para, + para_threshold_std=args.para_threshold, + para_min_pause=args.para_min_pause, + para_max_chars=args.para_max_chars, + para_max_words=args.para_max_words, + enable_logging=not args.no_log, + ai_enabled=args.ai, + ai_persona=args.ai_persona, + ai_model_index=args.ai_model, + ai_trigger_mode=args.ai_trigger, + ai_process_interval=args.ai_interval, + ai_process_words=args.ai_words, + voice_commands=args.voice_commands, + auto_stop_minutes=args.auto_stop, + autotype_mode=args.autotype, + ) if __name__ == "__main__": try: main() except KeyboardInterrupt: - # Graceful exit on Ctrl+C pass @@ -49,7 +49,7 @@ cd src python -m whispering_ui.main ``` -## Key Differences from tkinter Version +## Design Principles 1. **Separation of Concerns**: State, logic, and UI are completely decoupled 2. **Reactive Updates**: UI updates automatically when state changes @@ -61,10 +61,3 @@ python -m whispering_ui.main - `nicegui>=1.4.0` - Modern UI framework - `pyperclip` - Clipboard operations - -## Migration Notes - -- The original `gui.py` used tkinter with tight coupling between UI and logic -- This version maintains the same functionality but with clean separation -- The `core.proc` thread interface remains unchanged -- Settings persistence is maintained using the same `Settings` class