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.
- sybl/__init__.py +3 -0
- sybl/__main__.py +5 -0
- sybl/audio/__init__.py +38 -0
- sybl/audio/debug.py +41 -0
- sybl/audio/devices.py +224 -0
- sybl/audio/errors.py +17 -0
- sybl/audio/metering.py +77 -0
- sybl/audio/resample.py +25 -0
- sybl/audio/session.py +289 -0
- sybl/audio/types.py +34 -0
- sybl/cli/__init__.py +56 -0
- sybl/cli/audio_cmd.py +147 -0
- sybl/cli/config_cmd.py +164 -0
- sybl/cli/doctor.py +534 -0
- sybl/cli/hotkey_cmd.py +72 -0
- sybl/cli/io.py +13 -0
- sybl/cli/meter.py +32 -0
- sybl/cli/start.py +70 -0
- sybl/cli/status.py +39 -0
- sybl/cli/stop.py +32 -0
- sybl/cli/transcribe_cmd.py +169 -0
- sybl/cli/tui.py +30 -0
- sybl/config/__init__.py +29 -0
- sybl/config/manager.py +101 -0
- sybl/config/models.py +107 -0
- sybl/config/paths.py +34 -0
- sybl/config/vocabulary.py +94 -0
- sybl/core/__init__.py +17 -0
- sybl/core/daemon.py +305 -0
- sybl/core/dictation.py +340 -0
- sybl/core/events.py +95 -0
- sybl/core/history.py +63 -0
- sybl/core/postprocess.py +124 -0
- sybl/core/state.py +70 -0
- sybl/core/transcribe.py +189 -0
- sybl/core/voice_commands.py +43 -0
- sybl/hotkeys/__init__.py +16 -0
- sybl/hotkeys/base.py +40 -0
- sybl/hotkeys/bindings.py +95 -0
- sybl/hotkeys/focus.py +45 -0
- sybl/hotkeys/pynput_backend.py +197 -0
- sybl/indicator/__init__.py +22 -0
- sybl/indicator/base.py +15 -0
- sybl/indicator/cursor_win.py +31 -0
- sybl/indicator/noop.py +17 -0
- sybl/indicator/tk_win.py +187 -0
- sybl/inject/__init__.py +9 -0
- sybl/inject/base.py +32 -0
- sybl/inject/clipboard_win.py +253 -0
- sybl/inject/focus_win.py +56 -0
- sybl/ipc/__init__.py +12 -0
- sybl/ipc/client.py +175 -0
- sybl/ipc/process.py +28 -0
- sybl/ipc/protocol.py +76 -0
- sybl/ipc/server.py +192 -0
- sybl/ipc/single_instance.py +66 -0
- sybl/logging/__init__.py +6 -0
- sybl/logging/ring_buffer.py +36 -0
- sybl/logging/setup.py +49 -0
- sybl/providers/__init__.py +41 -0
- sybl/providers/base.py +17 -0
- sybl/providers/capabilities.py +51 -0
- sybl/providers/deepgram.py +213 -0
- sybl/providers/errors.py +21 -0
- sybl/providers/groq.py +141 -0
- sybl/providers/manager.py +90 -0
- sybl/providers/pcm.py +21 -0
- sybl/providers/registry.py +49 -0
- sybl/providers/types.py +12 -0
- sybl/secrets/__init__.py +19 -0
- sybl/secrets/store.py +52 -0
- sybl/tui/__init__.py +5 -0
- sybl/tui/app.py +178 -0
- sybl/tui/client.py +69 -0
- sybl/tui/screens/__init__.py +0 -0
- sybl/tui/screens/dashboard.py +51 -0
- sybl/tui/screens/onboarding.py +55 -0
- sybl/tui/screens/settings.py +147 -0
- sybl/tui/widgets/__init__.py +0 -0
- sybl/tui/widgets/history_panel.py +44 -0
- sybl/tui/widgets/log_view.py +17 -0
- sybl/tui/widgets/status_bar.py +29 -0
- sybl-0.1.0.dist-info/METADATA +123 -0
- sybl-0.1.0.dist-info/RECORD +87 -0
- sybl-0.1.0.dist-info/WHEEL +4 -0
- sybl-0.1.0.dist-info/entry_points.txt +2 -0
- sybl-0.1.0.dist-info/licenses/LICENSE +21 -0
sybl/__init__.py
ADDED
sybl/__main__.py
ADDED
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()
|