sybl 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.
Files changed (87) hide show
  1. sybl/__init__.py +3 -0
  2. sybl/__main__.py +5 -0
  3. sybl/audio/__init__.py +38 -0
  4. sybl/audio/debug.py +41 -0
  5. sybl/audio/devices.py +224 -0
  6. sybl/audio/errors.py +17 -0
  7. sybl/audio/metering.py +77 -0
  8. sybl/audio/resample.py +25 -0
  9. sybl/audio/session.py +289 -0
  10. sybl/audio/types.py +34 -0
  11. sybl/cli/__init__.py +56 -0
  12. sybl/cli/audio_cmd.py +147 -0
  13. sybl/cli/config_cmd.py +164 -0
  14. sybl/cli/doctor.py +534 -0
  15. sybl/cli/hotkey_cmd.py +72 -0
  16. sybl/cli/io.py +13 -0
  17. sybl/cli/meter.py +32 -0
  18. sybl/cli/start.py +70 -0
  19. sybl/cli/status.py +39 -0
  20. sybl/cli/stop.py +32 -0
  21. sybl/cli/transcribe_cmd.py +169 -0
  22. sybl/cli/tui.py +30 -0
  23. sybl/config/__init__.py +29 -0
  24. sybl/config/manager.py +101 -0
  25. sybl/config/models.py +107 -0
  26. sybl/config/paths.py +34 -0
  27. sybl/config/vocabulary.py +94 -0
  28. sybl/core/__init__.py +17 -0
  29. sybl/core/daemon.py +305 -0
  30. sybl/core/dictation.py +340 -0
  31. sybl/core/events.py +95 -0
  32. sybl/core/history.py +63 -0
  33. sybl/core/postprocess.py +124 -0
  34. sybl/core/state.py +70 -0
  35. sybl/core/transcribe.py +189 -0
  36. sybl/core/voice_commands.py +43 -0
  37. sybl/hotkeys/__init__.py +16 -0
  38. sybl/hotkeys/base.py +40 -0
  39. sybl/hotkeys/bindings.py +95 -0
  40. sybl/hotkeys/focus.py +45 -0
  41. sybl/hotkeys/pynput_backend.py +197 -0
  42. sybl/indicator/__init__.py +22 -0
  43. sybl/indicator/base.py +15 -0
  44. sybl/indicator/cursor_win.py +31 -0
  45. sybl/indicator/noop.py +17 -0
  46. sybl/indicator/tk_win.py +187 -0
  47. sybl/inject/__init__.py +9 -0
  48. sybl/inject/base.py +32 -0
  49. sybl/inject/clipboard_win.py +253 -0
  50. sybl/inject/focus_win.py +56 -0
  51. sybl/ipc/__init__.py +12 -0
  52. sybl/ipc/client.py +175 -0
  53. sybl/ipc/process.py +28 -0
  54. sybl/ipc/protocol.py +76 -0
  55. sybl/ipc/server.py +192 -0
  56. sybl/ipc/single_instance.py +66 -0
  57. sybl/logging/__init__.py +6 -0
  58. sybl/logging/ring_buffer.py +36 -0
  59. sybl/logging/setup.py +49 -0
  60. sybl/providers/__init__.py +41 -0
  61. sybl/providers/base.py +17 -0
  62. sybl/providers/capabilities.py +51 -0
  63. sybl/providers/deepgram.py +213 -0
  64. sybl/providers/errors.py +21 -0
  65. sybl/providers/groq.py +141 -0
  66. sybl/providers/manager.py +90 -0
  67. sybl/providers/pcm.py +21 -0
  68. sybl/providers/registry.py +49 -0
  69. sybl/providers/types.py +12 -0
  70. sybl/secrets/__init__.py +19 -0
  71. sybl/secrets/store.py +52 -0
  72. sybl/tui/__init__.py +5 -0
  73. sybl/tui/app.py +178 -0
  74. sybl/tui/client.py +69 -0
  75. sybl/tui/screens/__init__.py +0 -0
  76. sybl/tui/screens/dashboard.py +51 -0
  77. sybl/tui/screens/onboarding.py +55 -0
  78. sybl/tui/screens/settings.py +147 -0
  79. sybl/tui/widgets/__init__.py +0 -0
  80. sybl/tui/widgets/history_panel.py +44 -0
  81. sybl/tui/widgets/log_view.py +17 -0
  82. sybl/tui/widgets/status_bar.py +29 -0
  83. sybl-0.1.0.dist-info/METADATA +123 -0
  84. sybl-0.1.0.dist-info/RECORD +87 -0
  85. sybl-0.1.0.dist-info/WHEEL +4 -0
  86. sybl-0.1.0.dist-info/entry_points.txt +2 -0
  87. sybl-0.1.0.dist-info/licenses/LICENSE +21 -0
sybl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """sybl — open-source BYOK voice dictation."""
2
+
3
+ __version__ = "0.1.0"
sybl/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m sybl`."""
2
+
3
+ from sybl.cli import app
4
+
5
+ app()
sybl/audio/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Audio capture pipeline (Phase 1)."""
2
+
3
+ from sybl.audio.debug import (
4
+ debug_recording_dir,
5
+ default_recording_path,
6
+ save_wav,
7
+ )
8
+ from sybl.audio.devices import list_input_devices, resolve_device
9
+ from sybl.audio.errors import (
10
+ AudioError,
11
+ DeviceNotFoundError,
12
+ SessionError,
13
+ StreamError,
14
+ )
15
+ from sybl.audio.session import AudioCaptureSession
16
+ from sybl.audio.types import (
17
+ TARGET_SAMPLE_RATE,
18
+ AudioChunk,
19
+ CaptureStats,
20
+ DeviceInfo,
21
+ )
22
+
23
+ __all__ = [
24
+ "TARGET_SAMPLE_RATE",
25
+ "AudioCaptureSession",
26
+ "AudioChunk",
27
+ "AudioError",
28
+ "CaptureStats",
29
+ "DeviceInfo",
30
+ "DeviceNotFoundError",
31
+ "SessionError",
32
+ "StreamError",
33
+ "default_recording_path",
34
+ "debug_recording_dir",
35
+ "list_input_devices",
36
+ "resolve_device",
37
+ "save_wav",
38
+ ]
sybl/audio/debug.py ADDED
@@ -0,0 +1,41 @@
1
+ """Debug utilities for audio capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import soundfile as sf
10
+
11
+ from sybl.audio.types import TARGET_SAMPLE_RATE
12
+ from sybl.config.paths import state_dir
13
+
14
+ DEBUG_RECORDING_DIRNAME = "debug recording"
15
+ LAST_RECORDING_FILENAME = "last_recording.wav"
16
+
17
+
18
+ def debug_recording_dir() -> Path:
19
+ path = state_dir() / DEBUG_RECORDING_DIRNAME
20
+ path.mkdir(parents=True, exist_ok=True)
21
+ return path
22
+
23
+
24
+ def default_recording_path() -> Path:
25
+ return debug_recording_dir() / LAST_RECORDING_FILENAME
26
+
27
+
28
+ def timestamped_recording_path() -> Path:
29
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
30
+ return debug_recording_dir() / f"recording_{stamp}.wav"
31
+
32
+
33
+ def save_wav(
34
+ path: Path,
35
+ pcm: bytes,
36
+ sample_rate: int = TARGET_SAMPLE_RATE,
37
+ ) -> None:
38
+ """Write mono int16 PCM bytes to a WAV file."""
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ samples = np.frombuffer(pcm, dtype=np.int16)
41
+ sf.write(path, samples, sample_rate, subtype="PCM_16")
sybl/audio/devices.py ADDED
@@ -0,0 +1,224 @@
1
+ """Input device discovery and resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+
8
+ import sounddevice as sd
9
+
10
+ from sybl.audio.errors import DeviceNotFoundError
11
+ from sybl.audio.types import TARGET_SAMPLE_RATE, DeviceInfo
12
+
13
+
14
+ def _normalize_device_name(name: str) -> str:
15
+ return re.sub(r"\s+", " ", name.strip().lower())
16
+
17
+
18
+ def _hostapi_rank(hostapi_index: int) -> int:
19
+ hostapis = sd.query_hostapis()
20
+ api_name = str(hostapis[hostapi_index]["name"]).lower()
21
+ if sys.platform == "win32":
22
+ for rank, key in enumerate(("wasapi", "wdm", "directsound", "mme")):
23
+ if key in api_name:
24
+ return rank
25
+ return 99
26
+ if sys.platform == "darwin":
27
+ return 0 if "core" in api_name else 1
28
+ return 0 if "alsa" in api_name or "jack" in api_name else 1
29
+
30
+
31
+ def _can_open_input(device_index: int, samplerate: float) -> bool:
32
+ for rate in (int(samplerate), TARGET_SAMPLE_RATE, 44100, 48000):
33
+ try:
34
+ sd.check_input_settings(device=device_index, channels=1, samplerate=rate)
35
+ return True
36
+ except Exception:
37
+ continue
38
+ try:
39
+ sd.check_input_settings(device=device_index, channels=1)
40
+ return True
41
+ except Exception:
42
+ return False
43
+
44
+
45
+ def _dedupe_devices(devices: list[DeviceInfo]) -> list[DeviceInfo]:
46
+ best_by_name: dict[str, DeviceInfo] = {}
47
+ rank_by_name: dict[str, tuple[int, int, int]] = {}
48
+
49
+ for device in devices:
50
+ key = _normalize_device_name(device.name)
51
+ hostapi = sd.query_devices(device.index)["hostapi"]
52
+ rank = (
53
+ 0 if device.is_default else 1,
54
+ _hostapi_rank(int(hostapi)),
55
+ device.index,
56
+ )
57
+ existing = rank_by_name.get(key)
58
+ if existing is None or rank < existing:
59
+ best_by_name[key] = device
60
+ rank_by_name[key] = rank
61
+
62
+ return sorted(best_by_name.values(), key=lambda d: (not d.is_default, d.index))
63
+
64
+
65
+ def _wasapi_hostapi_index() -> int | None:
66
+ if sys.platform != "win32":
67
+ return None
68
+ for index, hostapi in enumerate(sd.query_hostapis()):
69
+ if "wasapi" in str(hostapi["name"]).lower():
70
+ return index
71
+ return None
72
+
73
+
74
+ def _is_likely_microphone(name: str) -> bool:
75
+ lowered = name.lower()
76
+ excluded = (
77
+ "stereo mix",
78
+ "loopback",
79
+ "wave out mix",
80
+ "what u hear",
81
+ "pc speaker",
82
+ "sound mapper",
83
+ "primary sound capture driver",
84
+ "mapper - output",
85
+ "input ()",
86
+ )
87
+ if any(token in lowered for token in excluded):
88
+ return False
89
+ if lowered.endswith(" output") or lowered.endswith(" output)"):
90
+ return False
91
+ return True
92
+
93
+
94
+ def list_input_devices(*, available_only: bool = True) -> list[DeviceInfo]:
95
+ """Return input-capable audio devices, optionally filtered to openable mics."""
96
+ default_input = sd.default.device[0]
97
+ wasapi_index = _wasapi_hostapi_index()
98
+ devices: list[DeviceInfo] = []
99
+
100
+ for index, device in enumerate(sd.query_devices()):
101
+ if device["max_input_channels"] <= 0:
102
+ continue
103
+ if wasapi_index is not None and int(device["hostapi"]) != wasapi_index:
104
+ continue
105
+ name = str(device["name"])
106
+ if available_only and not _is_likely_microphone(name):
107
+ continue
108
+ devices.append(
109
+ DeviceInfo(
110
+ index=index,
111
+ name=name,
112
+ default_samplerate=float(device["default_samplerate"]),
113
+ max_input_channels=int(device["max_input_channels"]),
114
+ is_default=index == default_input,
115
+ )
116
+ )
117
+
118
+ if available_only:
119
+ devices = [
120
+ device
121
+ for device in devices
122
+ if _can_open_input(device.index, device.default_samplerate)
123
+ ]
124
+ devices = _dedupe_devices(devices)
125
+ devices = _mark_default_device(devices, default_input)
126
+
127
+ return devices
128
+
129
+
130
+ def _mark_default_device(
131
+ devices: list[DeviceInfo],
132
+ default_input: int | None,
133
+ ) -> list[DeviceInfo]:
134
+ if not devices:
135
+ return devices
136
+ if any(device.is_default for device in devices):
137
+ return devices
138
+
139
+ default_name = ""
140
+ if default_input is not None and default_input >= 0:
141
+ default_name = _normalize_device_name(get_device_name(int(default_input)))
142
+
143
+ marked: list[DeviceInfo] = []
144
+ best_index: int | None = None
145
+ for device in devices:
146
+ name = _normalize_device_name(device.name)
147
+ if default_name and (
148
+ name == default_name or default_name in name or name in default_name
149
+ ):
150
+ best_index = device.index
151
+ break
152
+
153
+ if best_index is None:
154
+ best_index = devices[0].index
155
+
156
+ for device in devices:
157
+ marked.append(
158
+ DeviceInfo(
159
+ index=device.index,
160
+ name=device.name,
161
+ default_samplerate=device.default_samplerate,
162
+ max_input_channels=device.max_input_channels,
163
+ is_default=device.index == best_index,
164
+ )
165
+ )
166
+ return marked
167
+
168
+
169
+ def _format_device_list(devices: list[DeviceInfo]) -> str:
170
+ if not devices:
171
+ return "(no input devices found)"
172
+ lines = [f" [{d.index}] {d.name}" for d in devices]
173
+ return "\n".join(lines)
174
+
175
+
176
+ def resolve_device(spec: str | None) -> int:
177
+ """Resolve a device spec (None, index, or name substring) to a device index."""
178
+ devices = list_input_devices()
179
+
180
+ if spec is None or spec.strip() == "":
181
+ default_index = sd.default.device[0]
182
+ available_indices = {d.index for d in devices}
183
+ if default_index in available_indices:
184
+ return int(default_index)
185
+ if devices:
186
+ default_device = next((d for d in devices if d.is_default), devices[0])
187
+ return default_device.index
188
+ raise DeviceNotFoundError(
189
+ "No usable input device found.\n"
190
+ f"Available devices:\n{_format_device_list(devices)}"
191
+ )
192
+
193
+ spec_stripped = spec.strip()
194
+
195
+ if spec_stripped.isdigit():
196
+ index = int(spec_stripped)
197
+ device_indices = {d.index for d in devices}
198
+ if index not in device_indices:
199
+ raise DeviceNotFoundError(
200
+ f"Device index {index} not found.\n"
201
+ f"Available devices:\n{_format_device_list(devices)}"
202
+ )
203
+ return index
204
+
205
+ spec_lower = spec_stripped.lower()
206
+ matches = [d for d in devices if spec_lower in d.name.lower()]
207
+ if not matches:
208
+ raise DeviceNotFoundError(
209
+ f"No input device matching {spec_stripped!r}.\n"
210
+ f"Available devices:\n{_format_device_list(devices)}"
211
+ )
212
+
213
+ exact = [d for d in matches if d.name.lower() == spec_lower]
214
+ if exact:
215
+ return exact[0].index
216
+
217
+ matches.sort(key=lambda d: len(d.name))
218
+ return matches[0].index
219
+
220
+
221
+ def get_device_name(device_index: int) -> str:
222
+ """Return the human-readable name for a device index."""
223
+ device = sd.query_devices(device_index)
224
+ return str(device["name"])
sybl/audio/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """Audio capture exceptions."""
2
+
3
+
4
+ class AudioError(Exception):
5
+ """Base exception for audio capture errors."""
6
+
7
+
8
+ class DeviceNotFoundError(AudioError):
9
+ """Raised when the configured input device cannot be resolved."""
10
+
11
+
12
+ class StreamError(AudioError):
13
+ """Raised when the audio stream fails or is interrupted."""
14
+
15
+
16
+ class SessionError(AudioError):
17
+ """Raised when session lifecycle operations are invalid."""
sybl/audio/metering.py ADDED
@@ -0,0 +1,77 @@
1
+ """Signal level metering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import numpy as np
8
+
9
+ INT16_MAX = 32768.0
10
+ SILENCE_DBFS = -80.0
11
+ METER_FLOOR_DBFS = -55.0
12
+ METER_CEILING_DBFS = -10.0
13
+
14
+
15
+ def compute_rms(pcm: bytes) -> float:
16
+ """Return normalized RMS level in 0.0–1.0 from int16 PCM bytes."""
17
+ if not pcm:
18
+ return 0.0
19
+ samples = np.frombuffer(pcm, dtype=np.int16).astype(np.float64)
20
+ if samples.size == 0:
21
+ return 0.0
22
+ rms = float(np.sqrt(np.mean(samples**2)))
23
+ return min(rms / INT16_MAX, 1.0)
24
+
25
+
26
+ def compute_peak(pcm: bytes) -> float:
27
+ """Return normalized peak level in 0.0–1.0 from int16 PCM bytes."""
28
+ if not pcm:
29
+ return 0.0
30
+ samples = np.frombuffer(pcm, dtype=np.int16)
31
+ if samples.size == 0:
32
+ return 0.0
33
+ peak = float(np.max(np.abs(samples.astype(np.int32))))
34
+ return min(peak / INT16_MAX, 1.0)
35
+
36
+
37
+ def level_to_dbfs(normalized: float) -> float:
38
+ """Convert a normalized 0–1 level to dBFS."""
39
+ if normalized <= 0.0:
40
+ return SILENCE_DBFS
41
+ return max(SILENCE_DBFS, 20.0 * math.log10(normalized))
42
+
43
+
44
+ def pcm_peak_dbfs(pcm: bytes) -> float:
45
+ return level_to_dbfs(compute_peak(pcm))
46
+
47
+
48
+ def display_level_from_dbfs(
49
+ dbfs: float,
50
+ *,
51
+ floor_dbfs: float = METER_FLOOR_DBFS,
52
+ ceiling_dbfs: float = METER_CEILING_DBFS,
53
+ ) -> float:
54
+ """Map dBFS into 0.0–1.0 for a responsive level meter."""
55
+ if dbfs <= floor_dbfs:
56
+ return 0.0
57
+ if dbfs >= ceiling_dbfs:
58
+ return 1.0
59
+ return (dbfs - floor_dbfs) / (ceiling_dbfs - floor_dbfs)
60
+
61
+
62
+ def level_from_display(display: float) -> float:
63
+ """Convert smoothed 0–1 meter level back to dBFS for display."""
64
+ return METER_FLOOR_DBFS + display * (METER_CEILING_DBFS - METER_FLOOR_DBFS)
65
+
66
+
67
+ def apply_meter_ballistics(
68
+ current: float,
69
+ new_peak: float,
70
+ *,
71
+ attack: float = 0.55,
72
+ release: float = 0.12,
73
+ ) -> float:
74
+ """Fast attack, slower release — snappy but readable meter."""
75
+ if new_peak >= current:
76
+ return current + (new_peak - current) * attack
77
+ return current + (new_peak - current) * release
sybl/audio/resample.py ADDED
@@ -0,0 +1,25 @@
1
+ """PCM resampling to the STT target format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import soxr
7
+
8
+ from sybl.audio.types import TARGET_SAMPLE_RATE
9
+
10
+
11
+ def resample_pcm(
12
+ pcm: bytes,
13
+ source_rate: int,
14
+ target_rate: int = TARGET_SAMPLE_RATE,
15
+ ) -> bytes:
16
+ """Resample mono int16 PCM from source_rate to target_rate."""
17
+ if source_rate == target_rate:
18
+ return pcm
19
+ if not pcm:
20
+ return b""
21
+
22
+ samples = np.frombuffer(pcm, dtype=np.int16).astype(np.float32)
23
+ resampled = soxr.resample(samples, source_rate, target_rate, quality="HQ")
24
+ clipped = np.clip(resampled, -32768, 32767).astype(np.int16)
25
+ return clipped.tobytes()