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 +4 -0
- talk/__main__.py +85 -0
- talk/app.py +137 -0
- talk/audio.py +107 -0
- talk/backends/__init__.py +1 -0
- talk/backends/base.py +12 -0
- talk/backends/factory.py +24 -0
- talk/backends/openai_backend.py +31 -0
- talk/backends/parakeet_backend.py +29 -0
- talk/config.py +94 -0
- talk/doctor.py +226 -0
- talk/paste.py +28 -0
- talktype-0.1.0.dist-info/METADATA +125 -0
- talktype-0.1.0.dist-info/RECORD +16 -0
- talktype-0.1.0.dist-info/WHEEL +4 -0
- talktype-0.1.0.dist-info/entry_points.txt +2 -0
talk/__init__.py
ADDED
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."""
|
talk/backends/factory.py
ADDED
|
@@ -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,,
|