diff --git a/CLAUDE.md b/CLAUDE.md index b2074f1..a70a121 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ TalkType is a push-to-talk voice typing tool that works system-wide. Press F9, s ```bash # Linux dependencies -sudo apt install xdotool xclip portaudio19-dev +sudo apt install xautomation wmctrl x11-utils portaudio19-dev # macOS dependencies brew install portaudio @@ -197,7 +197,7 @@ TalkType has two recovery mechanisms: ### Platform Differences -Linux uses xdotool/xclip. Windows/macOS use pyautogui. The `is_terminal_window()` function has OS-specific terminal detection to choose the correct paste shortcut. +Linux uses xte (xautomation) to type text character by character; wmctrl/xprop handle window focus. Windows/macOS use pyautogui.write(). No clipboard or paste shortcuts are used. ## Testing Changes diff --git a/README.md b/README.md index fd598c0..77498df 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ +# scottnotrobot fork - hacked this for my own use only on linux + +there's a fix here for groq, which i'm liking + +but more importantly i found xdotool would differentiate terminals +from other apps like mousepad and synergy and use ctrl-v instead of +typing. i wanted talktype to ALWAYS emulate typing... e.g. sometimes +apps override ctrl-shift-v or whatever so plain typing just seems more +universal to me. in particular allowing it to type into a +synergy/deskflow/etc type external machine. + # TalkType **Push-to-talk voice typing for your terminal.** @@ -23,7 +34,7 @@ Built for developers who want: - **Cross-platform**: Linux, Windows, macOS - **Local Whisper**: Uses faster-whisper for fast, private transcription - **API mode**: Connect to any Whisper-compatible API server -- **Smart paste**: Auto-detects terminals vs other apps (Ctrl+Shift+V vs Ctrl+V) +- **Direct typing**: Text is typed character by character (xte) — no clipboard, no paste shortcuts - **Window focus**: Remembers where you started — switch apps while speaking - **Configurable**: Choose your hotkey, model size, and language @@ -33,7 +44,7 @@ Built for developers who want: ```bash git clone https://github.com/lmacan1/talktype.git && cd talktype -sudo apt install xdotool xclip portaudio19-dev +sudo apt install xautomation wmctrl x11-utils portaudio19-dev python3 -m venv venv && source venv/bin/activate pip install -e . talktype # Setup wizard launches automatically @@ -43,7 +54,7 @@ talktype # Setup wizard launches automatically ```bash # System dependencies -sudo apt install xdotool xclip portaudio19-dev +sudo apt install xautomation wmctrl x11-utils portaudio19-dev # Clone and install git clone https://github.com/lmacan1/talktype.git @@ -302,7 +313,7 @@ TalkType works in any browser text field — it's not just for terminals: 2. Press F9, speak, press F9 3. Your words appear in the browser -Since TalkType uses clipboard + standard paste (Ctrl+V / Cmd+V), it works anywhere that accepts pasted text. +Since TalkType types each character directly (no clipboard involved), it works anywhere that accepts keyboard input. ## Troubleshooting @@ -332,7 +343,7 @@ Make sure your microphone is set as the default input device in Windows Sound se 1. **Global hotkey capture** (pynput) — works even when other apps are focused 2. **Audio recording** (sounddevice) — captures from your microphone 3. **Local transcription** (faster-whisper) — Whisper running on your machine -4. **Smart paste** (pyperclip + OS-specific) — detects terminal vs other apps +4. **Direct typing** (xte on Linux, pyautogui elsewhere) — types each character into the focused window ``` [F9 Press] → Start Recording → [Speak] → [F9 Press] → Stop Recording @@ -341,14 +352,14 @@ Make sure your microphone is set as the default input device in Windows Sound se ↓ Focus Original Window ↓ - Paste Text + Type Text (xte) ``` ## Contributing Contributions welcome! Some ideas: - [ ] Voice activity detection (auto-stop on silence) -- [ ] Wayland support (wtype instead of xdotool) +- [ ] Wayland support (wtype instead of xte) - [ ] Tray icon / visual indicator - [ ] Custom vocabulary/prompts - [ ] Streaming transcription diff --git a/install.sh b/install.sh index 4ab9428..5fdd2d0 100755 --- a/install.sh +++ b/install.sh @@ -34,25 +34,25 @@ echo "Installing system dependencies..." case "$DISTRO" in ubuntu|debian|linuxmint) sudo apt-get update -qq - sudo apt-get install -y -qq xdotool xclip portaudio19-dev python3-venv + sudo apt-get install -y -qq xautomation wmctrl x11-utils portaudio19-dev python3-venv ;; fedora|centos|rhel) sudo dnf check-update || sudo yum check-update # Check if dnf is available, otherwise try yum - sudo dnf install -y xdotool xclip portaudio-devel python3-venv || \ - sudo yum install -y xdotool xclip portaudio-devel python3-venv + sudo dnf install -y xautomation wmctrl xorg-x11-utils portaudio-devel python3-venv || \ + sudo yum install -y xautomation wmctrl xorg-x11-utils portaudio-devel python3-venv ;; arch|manjaro) sudo pacman -Sy --noconfirm - sudo pacman -S --noconfirm xdotool xclip portaudio python-venv + sudo pacman -S --noconfirm xautomation wmctrl xorg-xprop portaudio python-venv ;; suse|opensuse|sles) sudo zypper refresh - sudo zypper install -y xdotool xclip portaudio-devel # venv comes with python in arch + sudo zypper install -y xautomation wmctrl xprop portaudio-devel # venv comes with python in arch ;; *) echo "Unsupported distribution: $DISTRO" echo "Attempting to install common dependencies. This might fail." - echo "Please install 'xdotool', 'xclip', 'portaudio-dev' (or equivalent), and 'python3-venv' manually." + echo "Please install 'xautomation' (for xte), 'wmctrl', 'xprop', 'portaudio-dev' (or equivalent), and 'python3-venv' manually." ;; esac diff --git a/pyproject.toml b/pyproject.toml index 65ef2a4..875abc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ dependencies = [ "scipy>=1.10.0", "sounddevice>=0.4.6", "pynput>=1.7.6", - "pyperclip>=1.8.2", "requests>=2.28.0", "rich>=13.0.0", "pyyaml>=6.0", diff --git a/requirements.txt b/requirements.txt index ebab61f..62063ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ numpy>=1.24.0 scipy>=1.10.0 sounddevice>=0.4.6 pynput>=1.7.6 -pyperclip>=1.8.2 requests>=2.28.0 rich>=13.0.0 pyyaml>=6.0 @@ -17,5 +16,5 @@ fastapi>=0.100.0 uvicorn>=0.22.0 python-multipart>=0.0.6 -# Windows/macOS keyboard simulation (Linux uses xdotool) +# Windows/macOS keyboard simulation (Linux uses xte from the xautomation package) pyautogui>=0.9.53; sys_platform != 'linux' diff --git a/talktype.py b/talktype.py index 74db7a1..70903e0 100644 --- a/talktype.py +++ b/talktype.py @@ -29,7 +29,6 @@ from pathlib import Path import numpy as np -import pyperclip import requests import sounddevice as sd from pynput import keyboard @@ -58,14 +57,12 @@ ] } - # === State === class State: IDLE = 0 RECORDING = 1 TRANSCRIBING = 2 - state = State.IDLE state_lock = threading.Lock() audio_chunks: list[np.ndarray] = [] @@ -84,7 +81,6 @@ class State: # Debug logging for paste investigation (set to True to diagnose issues) DEBUG_PASTE = False - class TranscriptionHistory: """Persists transcriptions to ~/.cache/talktype/history.jsonl for recovery.""" @@ -167,11 +163,9 @@ def get_pending_audio(self) -> bytes | None: pass return None - # === Config File Loading === CONFIG_PATH = Path.home() / ".config" / "talktype" / "config.yaml" - def load_config_file() -> dict: """Load config from YAML file if it exists.""" if CONFIG_PATH.exists(): @@ -182,7 +176,6 @@ def load_config_file() -> dict: pass return {} - # === Argument Parsing === def parse_args(): # Load config file first (CLI args will override) @@ -216,7 +209,7 @@ def parse_args(): ) parser.add_argument( "--api-model", - default=None, + default=trans.get("api_model"), help="Model name for OpenAI-compatible APIs (default: whisper-1)" ) parser.add_argument( @@ -263,20 +256,19 @@ def parse_args(): ) return parser.parse_args() - # === Dependency Checks === def check_dependencies(): """Verify system dependencies based on OS.""" if SYSTEM == "Linux": missing = [] - for cmd in ("xdotool", "xclip"): + for cmd in ("xte", "wmctrl", "xprop"): try: subprocess.run(["which", cmd], capture_output=True, check=True) except (subprocess.CalledProcessError, FileNotFoundError): missing.append(cmd) if missing: print(f"Missing Linux dependencies: {', '.join(missing)}") - print(f"Install with: sudo apt install {' '.join(missing)}") + print("Install with: sudo apt install xautomation wmctrl x11-utils") sys.exit(1) # Check microphone @@ -289,7 +281,6 @@ def check_dependencies(): print(f"Audio device error: {e}") sys.exit(1) - def load_whisper_model(): """Load local Whisper model if not using API.""" global whisper_model @@ -314,7 +305,6 @@ def load_whisper_model(): print("Or use --api flag to connect to a Whisper API server") sys.exit(1) - # === Audio Feedback === def beep(freq: float, duration: float, volume: float = 0.12): """Play beep without blocking.""" @@ -325,7 +315,6 @@ def beep(freq: float, duration: float, volume: float = 0.12): except: pass # Ignore audio errors - def beep_start(): beep(880, 0.08) @@ -338,7 +327,6 @@ def beep_error(): def beep_success(): beep(660, 0.08) - # === Terminal Title (visual status) === def set_terminal_title(title: str): """Set terminal window title for visual status.""" @@ -346,7 +334,6 @@ def set_terminal_title(title: str): sys.stdout.write(f"\033]0;{title}\007") sys.stdout.flush() - def show_status(status: str, detail: str = ""): """Show status in minimal mode (clears and centers).""" if not config.minimal: @@ -368,16 +355,21 @@ def show_status(status: str, detail: str = ""): sys.stdout.write(f"{'─' * 40}\n") sys.stdout.flush() - # === Window Management (OS-specific) === def get_active_window(): """Get the currently focused window identifier.""" try: if SYSTEM == "Linux": - return subprocess.check_output( - ["xdotool", "getactivewindow"], + # xte has no window-management commands, so use xprop instead + out = subprocess.check_output( + ["xprop", "-root", "_NET_ACTIVE_WINDOW"], stderr=subprocess.DEVNULL - ).strip() + ).decode() + # Output looks like: _NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00007 + window_id = out.rsplit(" ", 1)[-1].strip() + if not window_id.startswith("0x"): + return None + return window_id.encode() elif SYSTEM == "Windows": import ctypes return ctypes.windll.user32.GetForegroundWindow() @@ -389,15 +381,15 @@ def get_active_window(): return None return None - def focus_window(window_id): """Focus a specific window.""" if not window_id: return try: if SYSTEM == "Linux": + wid = window_id.decode() if isinstance(window_id, bytes) else str(window_id) subprocess.run( - ["xdotool", "windowactivate", "--sync", window_id], + ["wmctrl", "-i", "-a", wid], stderr=subprocess.DEVNULL ) elif SYSTEM == "Windows": @@ -410,7 +402,6 @@ def focus_window(window_id): except: pass - def is_terminal_window(window_id) -> bool: """Check if the window is a terminal.""" try: @@ -440,13 +431,11 @@ def is_terminal_window(window_id) -> bool: pass return False - # === Recording === def audio_callback(indata, frames, time_info, status): """Accumulate audio chunks.""" audio_chunks.append(indata.copy()) - def start_recording(): """Start recording from microphone.""" global stream, audio_chunks, target_window @@ -463,7 +452,6 @@ def start_recording(): set_terminal_title("🎤 RECORDING...") show_status("🎤 RECORDING", "Press hotkey to stop") - def stop_recording() -> np.ndarray: """Stop recording, return audio array.""" global stream @@ -479,7 +467,6 @@ def stop_recording() -> np.ndarray: return np.array([], dtype=np.float32) return np.concatenate(audio_chunks).flatten() - # === Transcription === # Common Whisper hallucinations on silence/noise # Phrases that indicate Whisper is hallucinating on silence @@ -501,7 +488,6 @@ def stop_recording() -> np.ndarray: "nope", "well", "right", "hey", "hi", "hello", "what", "hm", } - def is_hallucination(text: str) -> bool: """Check if text is likely a Whisper hallucination.""" t = text.lower().strip() @@ -515,7 +501,6 @@ def is_hallucination(text: str) -> bool: return any(phrase in t for phrase in HALLUCINATION_PHRASES) return False - def has_speech(audio: np.ndarray, threshold: float = 0.01, segment_ms: int = 50) -> bool: """Check if audio contains actual speech using segment-based detection. @@ -536,13 +521,11 @@ def has_speech(audio: np.ndarray, threshold: float = 0.01, segment_ms: int = 50) return False - def is_openai_api(url: str) -> bool: """Check if URL looks like an OpenAI-compatible API.""" openai_patterns = ["/v1/audio/transcriptions", "/v1/audio/", "openai", "groq", "deepgram"] return any(p in url.lower() for p in openai_patterns) - def transcribe_api(wav_buffer: io.BytesIO) -> str: """Transcribe using API (supports OpenAI-compatible and custom APIs).""" wav_buffer.seek(0) @@ -560,7 +543,11 @@ def transcribe_api(wav_buffer: io.BytesIO) -> str: files = {"file": ("audio.wav", wav_buffer, "audio/wav")} data = {"language": config.language} - resp = requests.post(config.api, files=files, data=data, timeout=240) + headers = {} + api_key = os.getenv("GROQ_API_KEY") or os.getenv("OPENAI_API_KEY") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + resp = requests.post(config.api, files=files, data=data, headers=headers, timeout=240) resp.raise_for_status() # Handle both JSON {"text": "..."} and plain text responses @@ -570,7 +557,6 @@ def transcribe_api(wav_buffer: io.BytesIO) -> str: except: return resp.text.strip() - def transcribe(audio: np.ndarray) -> str: """Transcribe audio to text.""" if len(audio) < SAMPLE_RATE * 0.5: # < 500ms @@ -606,7 +592,6 @@ def transcribe(audio: np.ndarray) -> str: return text - # === Paste === def paste_text(text: str): """Paste text into the target window.""" @@ -626,57 +611,42 @@ def paste_text(text: str): print(f"[DEBUG] text length: {len(text)}, preview: {text[:50]!r}") print(f"[DEBUG] call stack:\n{''.join(traceback.format_stack()[-4:-1])}") - # Save old clipboard - try: - old_clipboard = pyperclip.paste() - except: - old_clipboard = None - - # Set new clipboard - pyperclip.copy(text) - time.sleep(0.05) - # Focus original window focus_window(target_window) time.sleep(0.05) - # Determine paste shortcut - is_terminal = is_terminal_window(target_window) if target_window else False - if SYSTEM == "Linux": - key = "ctrl+shift+v" if is_terminal else "ctrl+v" + # Type the text one character at a time with xte. + # No clipboard, no Ctrl+V / Ctrl+Shift+V — each character is + # synthesized as its own key event, so this works identically in + # terminals, browsers, and plain X11 apps. + commands = [] + for ch in text: + if ch == "\n": + commands.append("key Return") + elif ch == "\t": + commands.append("key Tab") + elif ch == " ": + commands.append("key space") + else: + commands.append(f"str {ch}") + # Small per-character delay so slow apps don't drop events + commands.append("usleep 3000") if DEBUG_PASTE: - print(f"[DEBUG] xdotool sending: {key} (is_terminal={is_terminal})") - # Use --clearmodifiers to prevent interference from held modifier keys - # Use --delay to ensure clean key release - subprocess.run(["xdotool", "key", "--clearmodifiers", "--delay", "50", key], stderr=subprocess.DEVNULL) + print(f"[DEBUG] xte typing {len(text)} chars, one at a time") + subprocess.run(["xte", *commands], stderr=subprocess.DEVNULL) if DEBUG_PASTE: - print(f"[DEBUG] xdotool completed") + print(f"[DEBUG] xte completed") elif SYSTEM == "Windows": import pyautogui - if is_terminal: - # Windows Terminal and modern terminals use Ctrl+V - pyautogui.hotkey('ctrl', 'v') - else: - pyautogui.hotkey('ctrl', 'v') + # Type character by character instead of pasting + pyautogui.write(text, interval=0.01) elif SYSTEM == "Darwin": import pyautogui - pyautogui.hotkey('command', 'v', interval=0.05) # 50ms between keys for cold start reliability - - # Restore old clipboard (scale delay by text length to avoid race condition) - if old_clipboard: - def restore(): - # Base 1.0s + 10ms per 100 chars, capped at 3.0s - delay = min(3.0, max(1.0, 1.0 + len(text) * 0.0001)) - time.sleep(delay) - try: - pyperclip.copy(old_clipboard) - except: - pass - threading.Thread(target=restore, daemon=True).start() - + # Type character by character instead of pasting + pyautogui.write(text, interval=0.01) # === Main Logic === def transcribe_and_paste(audio: np.ndarray): @@ -712,7 +682,6 @@ def transcribe_and_paste(audio: np.ndarray): set_terminal_title("TalkType - Ready") show_status("● READY", "Press F9 to record") - def get_hotkey(key_name: str): """Convert key name string to pynput key.""" key_name = key_name.lower().strip() @@ -724,7 +693,6 @@ def get_hotkey(key_name: str): } return key_map.get(key_name, keyboard.Key.f9) - def create_hotkey_handler(hotkey): """Create the hotkey handler function.""" def on_press(key): @@ -754,7 +722,6 @@ def on_press(key): return on_press - def create_recovery_handler(recovery_key): """Create the recovery hotkey handler (re-paste last transcription).""" def on_press(key): @@ -787,7 +754,6 @@ def on_press(key): return on_press - def create_retry_handler(retry_key): """Create the retry hotkey handler (re-transcribe from saved audio).""" def on_press(key): @@ -849,7 +815,6 @@ def on_press(key): return on_press - class _WindowsLock: def __init__(self, handle): self._handle = handle @@ -860,7 +825,6 @@ def close(self): ctypes.windll.kernel32.CloseHandle(self._handle) self._handle = None - def acquire_instance_lock(): """Ensure only one instance of TalkType runs at a time.""" lock_file = Path.home() / ".cache" / "talktype" / "talktype.lock" @@ -901,7 +865,6 @@ def acquire_instance_lock(): print("TalkType is already running") sys.exit(1) - def main(): global config, history @@ -966,6 +929,5 @@ def signal_handler(sig, frame): with keyboard.Listener(on_press=combined_handler) as listener: listener.join() - if __name__ == "__main__": main()