talktype 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
talk/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """talk — local-first dictation for macOS."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
talk/__main__.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from talk import __version__
7
+
8
+
9
+ def build_parser() -> argparse.ArgumentParser:
10
+ parser = argparse.ArgumentParser(
11
+ prog="talk",
12
+ description="Local-first laptop dictation with a global hotkey.",
13
+ )
14
+ parser.add_argument(
15
+ "--version", action="version", version=f"%(prog)s {__version__}",
16
+ )
17
+ sub = parser.add_subparsers(dest="command", required=True)
18
+
19
+ sub.add_parser("run", help="Start the live dictation app")
20
+ sub.add_parser("doctor", help="Run setup preflight checks")
21
+
22
+ file_parser = sub.add_parser("transcribe-file", help="Transcribe a WAV file")
23
+ file_parser.add_argument("path", help="Path to a 16-bit PCM WAV file")
24
+ file_parser.add_argument(
25
+ "--paste",
26
+ action="store_true",
27
+ help="Paste transcription at cursor after transcription",
28
+ )
29
+
30
+ return parser
31
+
32
+
33
+ def _cmd_run() -> None:
34
+ from talk.app import DictationApp
35
+ from talk.config import load_settings
36
+
37
+ settings = load_settings()
38
+ app = DictationApp(settings)
39
+ app.run()
40
+
41
+
42
+ def _cmd_doctor() -> None:
43
+ from talk.config import load_settings
44
+ from talk.doctor import run_doctor
45
+
46
+ settings = load_settings()
47
+ code = run_doctor(settings)
48
+ if code:
49
+ sys.exit(code)
50
+
51
+
52
+ def _cmd_transcribe_file(path: str, paste: bool) -> None:
53
+ from talk.audio import load_wav_mono
54
+ from talk.backends.factory import build_backend
55
+ from talk.config import load_settings
56
+ from talk.paste import paste_text
57
+
58
+ settings = load_settings()
59
+ backend = build_backend(settings)
60
+ chunk = load_wav_mono(path)
61
+ text = backend.transcribe(chunk.samples, chunk.sample_rate).strip()
62
+
63
+ if not text:
64
+ print("[warn] No transcription text produced.")
65
+ sys.exit(1)
66
+
67
+ print(text)
68
+ if paste:
69
+ paste_text(text)
70
+
71
+
72
+ def main() -> None:
73
+ parser = build_parser()
74
+ args = parser.parse_args()
75
+
76
+ if args.command == "run":
77
+ _cmd_run()
78
+ elif args.command == "doctor":
79
+ _cmd_doctor()
80
+ elif args.command == "transcribe-file":
81
+ _cmd_transcribe_file(args.path, args.paste)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
talk/app.py ADDED
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import signal
4
+ import subprocess
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from pynput import keyboard
10
+
11
+ from talk.audio import AudioChunk, MicRecorder, RecorderError
12
+ from talk.backends.factory import build_backend
13
+ from talk.config import Settings
14
+ from talk.paste import paste_text
15
+
16
+
17
+ def _play_sound(filename: str) -> None:
18
+ sound_path = Path("/System/Library/Sounds") / filename
19
+ if not sound_path.exists():
20
+ return
21
+ subprocess.Popen(
22
+ ["afplay", str(sound_path)],
23
+ stdout=subprocess.DEVNULL,
24
+ stderr=subprocess.DEVNULL,
25
+ )
26
+
27
+
28
+ class DictationApp:
29
+ def __init__(self, settings: Settings) -> None:
30
+ self.settings = settings
31
+ self.backend = build_backend(settings)
32
+ self.recorder = MicRecorder(
33
+ sample_rate=settings.sample_rate,
34
+ channels=settings.channels,
35
+ )
36
+
37
+ self._lock = threading.Lock()
38
+ self._stop_event = threading.Event()
39
+ self._is_recording = False
40
+ self._is_transcribing = False
41
+
42
+ def _toggle_recording(self) -> None:
43
+ chunk: AudioChunk | None = None
44
+
45
+ with self._lock:
46
+ if self._is_transcribing:
47
+ print("[busy] Still transcribing previous clip.")
48
+ return
49
+
50
+ if not self._is_recording:
51
+ try:
52
+ self.recorder.start()
53
+ except Exception as exc: # noqa: BLE001
54
+ print(f"[error] Could not start recording: {exc}")
55
+ return
56
+
57
+ self._is_recording = True
58
+ _play_sound("Glass.aiff")
59
+ print("[rec] Recording... press hotkey again to stop.")
60
+ return
61
+
62
+ try:
63
+ chunk = self.recorder.stop()
64
+ except RecorderError as exc:
65
+ print(f"[error] Could not stop recording cleanly: {exc}")
66
+ self._is_recording = False
67
+ return
68
+
69
+ self._is_recording = False
70
+ self._is_transcribing = True
71
+
72
+ _play_sound("Pop.aiff")
73
+ worker = threading.Thread(
74
+ target=self._transcribe_and_emit,
75
+ args=(chunk,),
76
+ daemon=True,
77
+ )
78
+ worker.start()
79
+
80
+ def _transcribe_and_emit(self, chunk: AudioChunk) -> None:
81
+ try:
82
+ duration_seconds = 0.0
83
+ if chunk.sample_rate > 0:
84
+ duration_seconds = len(chunk.samples) / float(chunk.sample_rate)
85
+
86
+ if duration_seconds < self.settings.min_seconds:
87
+ print("[skip] Clip too short. Try speaking a bit longer.")
88
+ return
89
+
90
+ text = self.backend.transcribe(chunk.samples, chunk.sample_rate).strip()
91
+ if not text:
92
+ print("[skip] No speech detected.")
93
+ return
94
+
95
+ print(f"[text] {text}")
96
+ if self.settings.autopaste:
97
+ paste_text(text)
98
+ print("[paste] Inserted at current cursor.")
99
+ except Exception as exc: # noqa: BLE001
100
+ print(f"[error] Transcription failed: {exc}")
101
+ finally:
102
+ with self._lock:
103
+ self._is_transcribing = False
104
+
105
+ def _request_shutdown(self) -> None:
106
+ with self._lock:
107
+ if self._is_recording:
108
+ try:
109
+ self.recorder.stop()
110
+ except Exception: # noqa: BLE001
111
+ pass
112
+ self._is_recording = False
113
+ print("[exit] Shutting down dictation app.")
114
+ self._stop_event.set()
115
+
116
+ def run(self) -> None:
117
+ print(f"[ready] Backend: {self.backend.name}")
118
+ print(f"[ready] Toggle dictation: {self.settings.hotkey}")
119
+ print(f"[ready] Quit app: {self.settings.quit_hotkey}")
120
+
121
+ keymap = {
122
+ self.settings.hotkey: self._toggle_recording,
123
+ self.settings.quit_hotkey: self._request_shutdown,
124
+ }
125
+
126
+ signal.signal(signal.SIGTERM, lambda *_: self._request_shutdown())
127
+
128
+ listener = keyboard.GlobalHotKeys(keymap)
129
+ listener.start()
130
+ try:
131
+ while not self._stop_event.is_set():
132
+ time.sleep(0.15)
133
+ except KeyboardInterrupt:
134
+ self._request_shutdown()
135
+ finally:
136
+ listener.stop()
137
+
talk/audio.py ADDED
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import threading
5
+ import wave
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+ import sounddevice as sd
10
+
11
+
12
+ class RecorderError(RuntimeError):
13
+ """Raised when recording state is invalid."""
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class AudioChunk:
18
+ samples: np.ndarray
19
+ sample_rate: int
20
+
21
+
22
+ class MicRecorder:
23
+ def __init__(self, sample_rate: int, channels: int = 1) -> None:
24
+ self.sample_rate = sample_rate
25
+ self.channels = channels
26
+ self._stream: sd.InputStream | None = None
27
+ self._frames: list[np.ndarray] = []
28
+ self._lock = threading.Lock()
29
+
30
+ def _on_audio(self, indata: np.ndarray, frames: int, time_info: dict, status: sd.CallbackFlags) -> None:
31
+ del frames, time_info
32
+ if status:
33
+ print(f"[warn] audio callback status: {status}")
34
+ with self._lock:
35
+ self._frames.append(indata.copy())
36
+
37
+ def start(self) -> None:
38
+ if self._stream is not None:
39
+ raise RecorderError("Recorder is already running")
40
+
41
+ with self._lock:
42
+ self._frames = []
43
+
44
+ self._stream = sd.InputStream(
45
+ samplerate=self.sample_rate,
46
+ channels=self.channels,
47
+ dtype="float32",
48
+ callback=self._on_audio,
49
+ )
50
+ self._stream.start()
51
+
52
+ def stop(self) -> AudioChunk:
53
+ if self._stream is None:
54
+ raise RecorderError("Recorder is not running")
55
+
56
+ self._stream.stop()
57
+ self._stream.close()
58
+ self._stream = None
59
+
60
+ with self._lock:
61
+ if not self._frames:
62
+ merged = np.zeros((0,), dtype=np.float32)
63
+ else:
64
+ merged = np.concatenate(self._frames, axis=0).astype(np.float32)
65
+
66
+ if merged.ndim == 2:
67
+ if merged.shape[1] == 1:
68
+ merged = merged[:, 0]
69
+ else:
70
+ merged = merged.mean(axis=1)
71
+
72
+ return AudioChunk(samples=merged, sample_rate=self.sample_rate)
73
+
74
+
75
+ def to_wav_buffer(audio: np.ndarray, sample_rate: int) -> io.BytesIO:
76
+ clipped = np.clip(audio.astype(np.float32), -1.0, 1.0)
77
+ pcm16 = (clipped * 32767.0).astype(np.int16)
78
+
79
+ buffer = io.BytesIO()
80
+ with wave.open(buffer, "wb") as wav_file:
81
+ wav_file.setnchannels(1)
82
+ wav_file.setsampwidth(2)
83
+ wav_file.setframerate(sample_rate)
84
+ wav_file.writeframes(pcm16.tobytes())
85
+
86
+ buffer.seek(0)
87
+ buffer.name = "dictation.wav"
88
+ return buffer
89
+
90
+
91
+ def load_wav_mono(path: str) -> AudioChunk:
92
+ with wave.open(path, "rb") as wav_file:
93
+ channels = wav_file.getnchannels()
94
+ sample_rate = wav_file.getframerate()
95
+ sample_width = wav_file.getsampwidth()
96
+ frame_count = wav_file.getnframes()
97
+ raw = wav_file.readframes(frame_count)
98
+
99
+ if sample_width != 2:
100
+ raise ValueError("Only 16-bit PCM WAV files are supported for file transcription.")
101
+
102
+ audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32767.0
103
+
104
+ if channels > 1:
105
+ audio = audio.reshape(-1, channels).mean(axis=1)
106
+
107
+ return AudioChunk(samples=audio, sample_rate=sample_rate)
@@ -0,0 +1 @@
1
+ """Transcription backends."""
talk/backends/base.py ADDED
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+ import numpy as np
6
+
7
+
8
+ class DictationBackend(Protocol):
9
+ name: str
10
+
11
+ def transcribe(self, audio: np.ndarray, sample_rate: int) -> str:
12
+ """Convert mono float32 audio into text."""
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from talk.backends.base import DictationBackend
4
+ from talk.config import Settings
5
+
6
+
7
+ def build_backend(settings: Settings) -> DictationBackend:
8
+ if settings.backend == "parakeet":
9
+ from talk.backends.parakeet_backend import ParakeetBackend
10
+
11
+ return ParakeetBackend(model_id=settings.parakeet_model)
12
+
13
+ if settings.backend == "openai":
14
+ from talk.backends.openai_backend import OpenAIBackend
15
+
16
+ return OpenAIBackend(
17
+ api_key=settings.openai_api_key or "",
18
+ model=settings.openai_model,
19
+ language=settings.openai_language,
20
+ )
21
+
22
+ raise ValueError(
23
+ f"Unknown DICTATE_BACKEND='{settings.backend}'. Use 'parakeet' or 'openai'."
24
+ )
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from talk.audio import to_wav_buffer
6
+
7
+
8
+ class OpenAIBackend:
9
+ name = "openai"
10
+
11
+ def __init__(self, api_key: str, model: str, language: str | None = None) -> None:
12
+ if not api_key:
13
+ raise ValueError("OPENAI_API_KEY is required for the OpenAI backend")
14
+
15
+ from openai import OpenAI
16
+
17
+ self._client = OpenAI(api_key=api_key)
18
+ self.model = model
19
+ self.language = language
20
+
21
+ def transcribe(self, audio: np.ndarray, sample_rate: int) -> str:
22
+ payload = {
23
+ "model": self.model,
24
+ "file": to_wav_buffer(audio, sample_rate),
25
+ }
26
+ if self.language:
27
+ payload["language"] = self.language
28
+
29
+ transcript = self._client.audio.transcriptions.create(**payload)
30
+ text = getattr(transcript, "text", "")
31
+ return (text or "").strip()
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+
5
+ import numpy as np
6
+
7
+ from talk.audio import to_wav_buffer
8
+
9
+
10
+ class ParakeetBackend:
11
+ name = "parakeet"
12
+
13
+ def __init__(self, model_id: str) -> None:
14
+ self.model_id = model_id
15
+ print(f"[init] Loading Parakeet model ({model_id})...")
16
+ print("[init] First run downloads ~1.2 GB of weights. Subsequent runs are fast.")
17
+
18
+ from parakeet_mlx import from_pretrained
19
+
20
+ self._model = from_pretrained(model_id)
21
+ print("[init] Model ready.")
22
+
23
+ def transcribe(self, audio: np.ndarray, sample_rate: int) -> str:
24
+ wav_buffer = to_wav_buffer(audio, sample_rate)
25
+ with tempfile.NamedTemporaryFile(suffix=".wav") as tmp:
26
+ tmp.write(wav_buffer.getvalue())
27
+ tmp.flush()
28
+ result = self._model.transcribe(tmp.name)
29
+ return (getattr(result, "text", "") or "").strip()
talk/config.py ADDED
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from dotenv import load_dotenv
8
+
9
+ _XDG_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
10
+ CONFIG_DIR = _XDG_CONFIG / "talk"
11
+
12
+
13
+ _MODIFIER_MAP = {
14
+ "cmd": "<cmd>",
15
+ "command": "<cmd>",
16
+ "shift": "<shift>",
17
+ "ctrl": "<ctrl>",
18
+ "control": "<ctrl>",
19
+ "alt": "<alt>",
20
+ "option": "<alt>",
21
+ }
22
+
23
+ _KEY_MAP = {
24
+ "space": "<space>",
25
+ "enter": "<enter>",
26
+ "return": "<enter>",
27
+ "tab": "<tab>",
28
+ "esc": "<esc>",
29
+ "escape": "<esc>",
30
+ }
31
+
32
+
33
+ def _as_bool(value: str | None, default: bool) -> bool:
34
+ if value is None:
35
+ return default
36
+ return value.strip().lower() in {"1", "true", "yes", "y", "on"}
37
+
38
+
39
+ def normalize_hotkey(raw_hotkey: str) -> str:
40
+ parts = [part.strip().lower() for part in raw_hotkey.split("+") if part.strip()]
41
+ normalized = []
42
+ for part in parts:
43
+ if part in _MODIFIER_MAP:
44
+ normalized.append(_MODIFIER_MAP[part])
45
+ elif part in _KEY_MAP:
46
+ normalized.append(_KEY_MAP[part])
47
+ elif part.startswith("<") and part.endswith(">"):
48
+ inner = part[1:-1].lower()
49
+ if inner in _KEY_MAP:
50
+ normalized.append(_KEY_MAP[inner])
51
+ else:
52
+ normalized.append(f"<{inner}>")
53
+ else:
54
+ normalized.append(part)
55
+ return "+".join(normalized)
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class Settings:
60
+ backend: str
61
+ hotkey: str
62
+ quit_hotkey: str
63
+ autopaste: bool
64
+ sample_rate: int
65
+ channels: int
66
+ min_seconds: float
67
+ parakeet_model: str
68
+ openai_api_key: str | None
69
+ openai_model: str
70
+ openai_language: str | None
71
+
72
+
73
+
74
+ def load_settings() -> Settings:
75
+ load_dotenv()
76
+ load_dotenv(CONFIG_DIR / ".env")
77
+
78
+ backend = os.getenv("DICTATE_BACKEND", "parakeet").strip().lower()
79
+ hotkey = normalize_hotkey(os.getenv("DICTATE_HOTKEY", "<cmd>+<shift>+<space>"))
80
+ quit_hotkey = normalize_hotkey(os.getenv("DICTATE_QUIT_HOTKEY", "<cmd>+<shift>+q"))
81
+
82
+ return Settings(
83
+ backend=backend,
84
+ hotkey=hotkey,
85
+ quit_hotkey=quit_hotkey,
86
+ autopaste=_as_bool(os.getenv("DICTATE_AUTOPASTE"), True),
87
+ sample_rate=int(os.getenv("DICTATE_SAMPLE_RATE", "16000")),
88
+ channels=int(os.getenv("DICTATE_CHANNELS", "1")),
89
+ min_seconds=float(os.getenv("DICTATE_MIN_SECONDS", "0.35")),
90
+ parakeet_model=os.getenv("PARAKEET_MODEL", "mlx-community/parakeet-tdt-0.6b-v3").strip(),
91
+ openai_api_key=os.getenv("OPENAI_API_KEY"),
92
+ openai_model=os.getenv("OPENAI_TRANSCRIBE_MODEL", "gpt-4o-transcribe").strip(),
93
+ openai_language=(os.getenv("OPENAI_TRANSCRIBE_LANGUAGE") or "").strip() or None,
94
+ )
talk/doctor.py ADDED
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ import sys
5
+ from dataclasses import dataclass
6
+
7
+ import sounddevice as sd
8
+
9
+ from talk.config import Settings
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class CheckResult:
14
+ name: str
15
+ ok: bool
16
+ message: str
17
+
18
+
19
+ def _check_platform() -> CheckResult:
20
+ system = platform.system()
21
+ machine = platform.machine().lower()
22
+ if system != "Darwin":
23
+ return CheckResult(
24
+ name="platform",
25
+ ok=False,
26
+ message=f"Detected {system}. This build is currently optimized for macOS (Darwin).",
27
+ )
28
+
29
+ if machine != "arm64":
30
+ return CheckResult(
31
+ name="platform",
32
+ ok=True,
33
+ message=f"Running on macOS {machine}. Works, but Parakeet-MLX is best on Apple Silicon.",
34
+ )
35
+
36
+ return CheckResult(
37
+ name="platform",
38
+ ok=True,
39
+ message="Running on macOS arm64 (Apple Silicon), ideal for Parakeet-MLX.",
40
+ )
41
+
42
+
43
+ def _check_microphone_device() -> CheckResult:
44
+ try:
45
+ devices = sd.query_devices()
46
+ except Exception as exc: # noqa: BLE001
47
+ return CheckResult(
48
+ name="microphone_device",
49
+ ok=False,
50
+ message=f"Could not enumerate audio devices: {exc}",
51
+ )
52
+
53
+ has_input = any((device.get("max_input_channels") or 0) > 0 for device in devices)
54
+ if not has_input:
55
+ return CheckResult(
56
+ name="microphone_device",
57
+ ok=False,
58
+ message="No input-capable microphone device found.",
59
+ )
60
+
61
+ default_device = sd.default.device
62
+ return CheckResult(
63
+ name="microphone_device",
64
+ ok=True,
65
+ message=f"Input device detected. Default device tuple: {default_device}.",
66
+ )
67
+
68
+
69
+ def _check_microphone_access(settings: Settings) -> CheckResult:
70
+ stream = None
71
+ try:
72
+ stream = sd.InputStream(
73
+ samplerate=settings.sample_rate,
74
+ channels=settings.channels,
75
+ dtype="float32",
76
+ )
77
+ stream.start()
78
+ stream.stop()
79
+ return CheckResult(
80
+ name="microphone_access",
81
+ ok=True,
82
+ message="Microphone stream opened successfully.",
83
+ )
84
+ except Exception as exc: # noqa: BLE001
85
+ return CheckResult(
86
+ name="microphone_access",
87
+ ok=False,
88
+ message=f"Microphone stream failed. Grant microphone permission in macOS settings. Error: {exc}",
89
+ )
90
+ finally:
91
+ if stream is not None:
92
+ try:
93
+ stream.close()
94
+ except Exception: # noqa: BLE001
95
+ pass
96
+
97
+
98
+ def _check_accessibility_permission() -> CheckResult:
99
+ if platform.system() != "Darwin":
100
+ return CheckResult(
101
+ name="accessibility_permission",
102
+ ok=False,
103
+ message="Accessibility permission check is only available on macOS.",
104
+ )
105
+
106
+ try:
107
+ from ApplicationServices import AXIsProcessTrusted
108
+ except Exception as exc: # noqa: BLE001
109
+ return CheckResult(
110
+ name="accessibility_permission",
111
+ ok=False,
112
+ message=f"Could not load macOS accessibility APIs: {exc}",
113
+ )
114
+
115
+ trusted = bool(AXIsProcessTrusted())
116
+ if trusted:
117
+ return CheckResult(
118
+ name="accessibility_permission",
119
+ ok=True,
120
+ message="Accessibility permission is granted.",
121
+ )
122
+
123
+ return CheckResult(
124
+ name="accessibility_permission",
125
+ ok=False,
126
+ message=(
127
+ "Accessibility permission is not granted. Add your terminal/Codex app in "
128
+ "System Settings > Privacy & Security > Accessibility."
129
+ ),
130
+ )
131
+
132
+
133
+ def _check_input_monitoring_permission() -> CheckResult:
134
+ if platform.system() != "Darwin":
135
+ return CheckResult(
136
+ name="input_monitoring_permission",
137
+ ok=False,
138
+ message="Input Monitoring check is only available on macOS.",
139
+ )
140
+
141
+ try:
142
+ from Quartz import CGPreflightListenEventAccess
143
+ except Exception as exc: # noqa: BLE001
144
+ return CheckResult(
145
+ name="input_monitoring_permission",
146
+ ok=False,
147
+ message=f"Could not load Input Monitoring API: {exc}",
148
+ )
149
+
150
+ trusted = bool(CGPreflightListenEventAccess())
151
+ if trusted:
152
+ return CheckResult(
153
+ name="input_monitoring_permission",
154
+ ok=True,
155
+ message="Input Monitoring permission is granted.",
156
+ )
157
+
158
+ return CheckResult(
159
+ name="input_monitoring_permission",
160
+ ok=False,
161
+ message=(
162
+ "Input Monitoring permission is not granted. Add your terminal/Codex app in "
163
+ "System Settings > Privacy & Security > Input Monitoring."
164
+ ),
165
+ )
166
+
167
+
168
+ def _check_backend_config(settings: Settings) -> CheckResult:
169
+ if settings.backend == "parakeet":
170
+ return CheckResult(
171
+ name="backend_config",
172
+ ok=True,
173
+ message=f"Backend is parakeet (model: {settings.parakeet_model}).",
174
+ )
175
+
176
+ if settings.backend == "openai":
177
+ if settings.openai_api_key:
178
+ return CheckResult(
179
+ name="backend_config",
180
+ ok=True,
181
+ message="Backend is openai and OPENAI_API_KEY is set.",
182
+ )
183
+ return CheckResult(
184
+ name="backend_config",
185
+ ok=False,
186
+ message="Backend is openai but OPENAI_API_KEY is missing.",
187
+ )
188
+
189
+ return CheckResult(
190
+ name="backend_config",
191
+ ok=False,
192
+ message=f"Unknown backend value: {settings.backend}",
193
+ )
194
+
195
+
196
+ def run_doctor(settings: Settings) -> int:
197
+ print("[doctor] talk preflight checks")
198
+ print(f"[doctor] Python: {sys.version.split()[0]}")
199
+ print(f"[doctor] Backend: {settings.backend}")
200
+ print(f"[doctor] Dictate hotkey: {settings.hotkey}")
201
+ print(f"[doctor] Quit hotkey: {settings.quit_hotkey}")
202
+ print()
203
+
204
+ checks = [
205
+ _check_platform(),
206
+ _check_backend_config(settings),
207
+ _check_microphone_device(),
208
+ _check_microphone_access(settings),
209
+ _check_accessibility_permission(),
210
+ _check_input_monitoring_permission(),
211
+ ]
212
+
213
+ failed = 0
214
+ for check in checks:
215
+ status = "ok" if check.ok else "fail"
216
+ print(f"[{status}] {check.name}: {check.message}")
217
+ if not check.ok:
218
+ failed += 1
219
+
220
+ print()
221
+ if failed:
222
+ print(f"[doctor] Completed with {failed} failing check(s).")
223
+ return 1
224
+
225
+ print("[doctor] All checks passed. You are ready to run live dictation.")
226
+ return 0
talk/paste.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import time
5
+
6
+
7
+ def paste_text(text: str, restore_clipboard: bool = True) -> None:
8
+ previous_clipboard = None
9
+ if restore_clipboard:
10
+ prior = subprocess.run(["pbpaste"], capture_output=True, text=True, check=False)
11
+ if prior.returncode == 0:
12
+ previous_clipboard = prior.stdout
13
+
14
+ subprocess.run(["pbcopy"], input=text, text=True, check=True)
15
+
16
+ # Requires macOS Accessibility permission for Terminal/Codex app.
17
+ subprocess.run(
18
+ [
19
+ "osascript",
20
+ "-e",
21
+ 'tell application "System Events" to keystroke "v" using command down',
22
+ ],
23
+ check=False,
24
+ )
25
+
26
+ if restore_clipboard and previous_clipboard is not None:
27
+ time.sleep(0.15)
28
+ subprocess.run(["pbcopy"], input=previous_clipboard, text=True, check=False)
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: talktype
3
+ Version: 0.1.0
4
+ Summary: Local-first dictation for macOS — press a hotkey, talk, text appears at your cursor
5
+ Project-URL: Homepage, https://github.com/strangeloopcanon/talk
6
+ Project-URL: Repository, https://github.com/strangeloopcanon/talk
7
+ Project-URL: Issues, https://github.com/strangeloopcanon/talk/issues
8
+ Author: Rohit Krishnan
9
+ License: MIT
10
+ Keywords: dictation,macos,mlx,parakeet,speech-to-text,transcription
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: MacOS X
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: numpy>=1.26
22
+ Requires-Dist: openai>=1.108.0
23
+ Requires-Dist: parakeet-mlx>=0.4.2
24
+ Requires-Dist: pynput>=1.8.1
25
+ Requires-Dist: python-dotenv>=1.1.1
26
+ Requires-Dist: sounddevice>=0.5.2
27
+ Description-Content-Type: text/markdown
28
+
29
+ # talk
30
+
31
+ > **macOS only** (Apple Silicon recommended). Requires Python 3.11+.
32
+
33
+ Local-first dictation for macOS.
34
+ Press a hotkey, talk, text appears at your cursor.
35
+
36
+ - Parakeet local transcription by default (zero API cost on Apple Silicon).
37
+ - OpenAI `gpt-4o-transcribe` fallback with one env switch.
38
+ - Automatic paste at cursor (clipboard-safe restore).
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install talk
44
+ ```
45
+
46
+ That's it. On first run, Parakeet model weights (~1.2 GB) download automatically.
47
+
48
+ ### Alternative: from source
49
+
50
+ ```bash
51
+ git clone https://github.com/strangeloopcanon/talk.git
52
+ cd talk
53
+ ./scripts/install_macos.sh
54
+ ./scripts/doctor_macos.sh
55
+ ./scripts/run_macos.sh
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ```bash
61
+ talk run
62
+ ```
63
+
64
+ - Press `Cmd+Shift+Space` to start recording.
65
+ - Press `Cmd+Shift+Space` again to stop, transcribe, and paste.
66
+ - Press `Cmd+Shift+Q` to quit.
67
+
68
+ ### Preflight checks
69
+
70
+ ```bash
71
+ talk doctor
72
+ ```
73
+
74
+ ### File transcription
75
+
76
+ ```bash
77
+ talk transcribe-file /path/to/sample.wav
78
+ ```
79
+
80
+ ## macOS permissions
81
+
82
+ You must grant your terminal app:
83
+
84
+ - **Microphone** -- for audio recording
85
+ - **Accessibility** -- for paste keystroke automation
86
+ - **Input Monitoring** -- for global hotkeys
87
+
88
+ If paste fails but transcription works, Accessibility permission is usually the issue.
89
+
90
+ ## Configuration
91
+
92
+ Works out of the box with zero configuration. To customize, create `~/.config/talk/.env`:
93
+
94
+ ```bash
95
+ mkdir -p ~/.config/talk
96
+ ```
97
+
98
+ Key options (all have sensible defaults):
99
+
100
+ | Variable | Default | Notes |
101
+ |----------|---------|-------|
102
+ | `DICTATE_BACKEND` | `parakeet` | `parakeet` (local) or `openai` (cloud) |
103
+ | `DICTATE_HOTKEY` | `<cmd>+<shift>+<space>` | Toggle recording |
104
+ | `DICTATE_QUIT_HOTKEY` | `<cmd>+<shift>+q` | Quit the app |
105
+ | `DICTATE_AUTOPASTE` | `true` | Paste transcription at cursor |
106
+ | `PARAKEET_MODEL` | `mlx-community/parakeet-tdt-0.6b-v3` | Local model |
107
+ | `OPENAI_API_KEY` | *(empty)* | Required if backend=openai |
108
+
109
+ See `.env.example` for the full list.
110
+
111
+ ## Switching backend
112
+
113
+ ```bash
114
+ # In ~/.config/talk/.env or as env vars:
115
+ DICTATE_BACKEND=openai
116
+ OPENAI_API_KEY=sk-...
117
+ ```
118
+
119
+ ## Cleanup
120
+
121
+ Parakeet model weights are cached in `~/.cache/huggingface/hub/`. To reclaim disk space:
122
+
123
+ ```bash
124
+ rm -rf ~/.cache/huggingface/hub/models--mlx-community--parakeet-tdt-0.6b-v3
125
+ ```
@@ -0,0 +1,16 @@
1
+ talk/__init__.py,sha256=rZ23pjPHZeZNPyo5gXZg3FnNEs5WhjsnWmbZUf0zmws,97
2
+ talk/__main__.py,sha256=ef-UYzxQcuxJEzjeTPorWMnG-QvxtJldLZL5KIJoY-g,2180
3
+ talk/app.py,sha256=72gAyY_lpmC3zb36JL8TyljGoD9db0cRb3n40A66ir8,4295
4
+ talk/audio.py,sha256=VD9OkkcW0cxmMwIe3auVFEpRS3xIRmlgkA3TxfDtMUY,3129
5
+ talk/config.py,sha256=6ig1Onk3qKHIL6ZLnnHdnw1FG8N5svWOdK7RVyJlc-U,2740
6
+ talk/doctor.py,sha256=8ruGeMK6LlF06mGkZOZpGzM_tt0OUf3s50LtjPp3Zz8,6570
7
+ talk/paste.py,sha256=9gV79zIKqr-0OpJAo_-uiQuo3Q8vY9AkWZUpfm5gJ20,865
8
+ talk/backends/__init__.py,sha256=45iJg8DVzIPGRLOpOWeXbGPwqseA8w0neF-Gh-MW-Zc,30
9
+ talk/backends/base.py,sha256=idxqyxwpHdTEt1k9KT_lU2mSjAK96fBpScxUUxgRttA,257
10
+ talk/backends/factory.py,sha256=N2CdKT0D1IHJRFnfdeCQTjvUmV9jQJyMzsPr34A_FIA,745
11
+ talk/backends/openai_backend.py,sha256=9sWtUo1--_T-qdsATdvvvHRgEdpoouX1cih3mDQw-yg,893
12
+ talk/backends/parakeet_backend.py,sha256=w8PRZm2llakZlcgTe0txNM19IizXIS4G8LihRDKZxK0,900
13
+ talktype-0.1.0.dist-info/METADATA,sha256=RuKBzt9dM-rifzVfLoJxYHQpdR3pke_483fJW3Fomm4,3375
14
+ talktype-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ talktype-0.1.0.dist-info/entry_points.txt,sha256=GzJ7dfM32L_VtWnAUoqYF69KUfL8-3XQlr022DfJIXM,44
16
+ talktype-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ talk = talk.__main__:main