supervoxtral 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.
svx/core/audio.py ADDED
@@ -0,0 +1,256 @@
1
+ """
2
+ Audio utilities for SuperVoxtral.
3
+
4
+ This module provides:
5
+ - WAV recording from microphone to a file.
6
+ - ffmpeg detection.
7
+ - Conversion from WAV to MP3 or Opus using ffmpeg.
8
+ - Optional helpers for listing/selecting audio input devices.
9
+
10
+ Dependencies:
11
+ - sounddevice
12
+ - soundfile
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import queue
19
+ import subprocess
20
+ import time
21
+ from pathlib import Path
22
+ from threading import Event, Thread
23
+ from typing import Any
24
+
25
+ import sounddevice as sd
26
+ import soundfile as sf
27
+
28
+ __all__ = [
29
+ "timestamp",
30
+ "detect_ffmpeg",
31
+ "convert_audio",
32
+ "record_wav",
33
+ "list_input_devices",
34
+ "default_input_device_index",
35
+ ]
36
+
37
+
38
+ def timestamp() -> str:
39
+ """
40
+ Return a compact timestamp suitable for filenames: YYYYMMDD_HHMMSS.
41
+ """
42
+ return time.strftime("%Y%m%d_%H%M%S")
43
+
44
+
45
+ def detect_ffmpeg() -> str | None:
46
+ """
47
+ Return 'ffmpeg' if available on PATH, otherwise None.
48
+ """
49
+ try:
50
+ subprocess.run(
51
+ ["ffmpeg", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
52
+ )
53
+ return "ffmpeg"
54
+ except Exception:
55
+ return None
56
+
57
+
58
+ def convert_audio(input_wav: Path, fmt: str) -> Path:
59
+ """
60
+ Convert a WAV file to the target compressed audio format using ffmpeg.
61
+
62
+ Args:
63
+ input_wav: Path to the source WAV file.
64
+ fmt: Target format, one of {'mp3', 'opus'}.
65
+
66
+ Returns:
67
+ Path to the converted file.
68
+
69
+ Raises:
70
+ AssertionError: If fmt is not supported.
71
+ RuntimeError: If ffmpeg is not available or conversion fails.
72
+ """
73
+ assert fmt in {"mp3", "opus"}, "fmt must be 'mp3' or 'opus'"
74
+ ffmpeg_bin = detect_ffmpeg()
75
+ if not ffmpeg_bin:
76
+ raise RuntimeError("ffmpeg not found. Please install ffmpeg (e.g., brew install ffmpeg).")
77
+
78
+ output_path = input_wav.with_suffix(f".{fmt}")
79
+ if fmt == "mp3":
80
+ cmd = [
81
+ ffmpeg_bin,
82
+ "-y",
83
+ "-i",
84
+ str(input_wav),
85
+ "-codec:a",
86
+ "libmp3lame",
87
+ "-q:a",
88
+ "3",
89
+ str(output_path),
90
+ ]
91
+ else: # opus
92
+ cmd = [
93
+ ffmpeg_bin,
94
+ "-y",
95
+ "-i",
96
+ str(input_wav),
97
+ "-c:a",
98
+ "libopus",
99
+ "-b:a",
100
+ "24k",
101
+ str(output_path),
102
+ ]
103
+
104
+ logging.info("Running ffmpeg: %s", " ".join(cmd))
105
+ proc = subprocess.run(cmd, capture_output=True, text=True)
106
+ if proc.returncode != 0:
107
+ logging.error("ffmpeg failed: %s", proc.stderr.strip())
108
+ raise RuntimeError(f"ffmpeg conversion failed with code {proc.returncode}")
109
+ return output_path
110
+
111
+
112
+ def record_wav(
113
+ output_path: Path,
114
+ samplerate: int = 16000,
115
+ channels: int = 1,
116
+ device: int | str | None = None,
117
+ duration_seconds: float | None = None,
118
+ stop_event: Event | None = None,
119
+ ) -> float:
120
+ """
121
+ Record audio from the default (or specified) input device to a WAV file.
122
+
123
+ This function records until one of the following happens:
124
+ - `duration_seconds` elapses (if provided)
125
+ - `stop_event` is set (if provided)
126
+ - a KeyboardInterrupt/EOFError is received
127
+
128
+ Note: This function does not handle any interactive UI. If you want a
129
+ "press Enter to stop" behavior, the caller should manage input and set
130
+ the provided `stop_event` accordingly.
131
+
132
+ Args:
133
+ output_path: Destination WAV file path.
134
+ samplerate: Sample rate in Hz (e.g., 16000 or 32000).
135
+ channels: Number of channels (1=mono, 2=stereo).
136
+ device: Input device index or name. None uses the default device.
137
+ duration_seconds: Fixed recording duration. If None, run until stop_event or interrupt.
138
+ stop_event: External stop flag. If None and duration_seconds is None, waits for interrupt.
139
+
140
+ Returns:
141
+ The recorded duration in seconds (float).
142
+ """
143
+ if channels < 1:
144
+ raise ValueError("channels must be >= 1")
145
+ if samplerate <= 0:
146
+ raise ValueError("samplerate must be > 0")
147
+
148
+ q: queue.Queue = queue.Queue()
149
+ writer_stop = Event()
150
+ start_time = time.time()
151
+
152
+ def audio_callback(indata, frames, time_info, status):
153
+ if status:
154
+ logging.warning("SoundDevice status: %s", status)
155
+ q.put(indata.copy())
156
+
157
+ def writer_thread(wav_file: sf.SoundFile) -> None:
158
+ while not writer_stop.is_set():
159
+ try:
160
+ data = q.get(timeout=0.1)
161
+ wav_file.write(data)
162
+ except queue.Empty:
163
+ continue
164
+ except Exception as e:
165
+ logging.exception("Error writing WAV data: %s", e)
166
+ writer_stop.set()
167
+
168
+ # Ensure parent directory exists
169
+ output_path.parent.mkdir(parents=True, exist_ok=True)
170
+
171
+ with sf.SoundFile(
172
+ str(output_path),
173
+ mode="w",
174
+ samplerate=samplerate,
175
+ channels=channels,
176
+ subtype="PCM_16",
177
+ ) as wav_file:
178
+ with sd.InputStream(
179
+ samplerate=samplerate,
180
+ channels=channels,
181
+ dtype="int16",
182
+ device=device,
183
+ callback=audio_callback,
184
+ ):
185
+ t = Thread(target=writer_thread, args=(wav_file,), daemon=True)
186
+ t.start()
187
+
188
+ try:
189
+ if duration_seconds is not None:
190
+ # Fixed-duration recording
191
+ end_time = start_time + float(duration_seconds)
192
+ while time.time() < end_time:
193
+ if stop_event is not None and stop_event.is_set():
194
+ break
195
+ time.sleep(0.05)
196
+ else:
197
+ # Indefinite recording until stop_event or interrupt
198
+ while True:
199
+ if stop_event is not None and stop_event.is_set():
200
+ break
201
+ time.sleep(0.05)
202
+ except (KeyboardInterrupt, EOFError):
203
+ # Graceful stop on user interrupt
204
+ pass
205
+ finally:
206
+ writer_stop.set()
207
+ t.join()
208
+
209
+ duration = time.time() - start_time
210
+ logging.info(
211
+ "Recorded WAV %s (%.2fs @ %d Hz, %d ch)", output_path, duration, samplerate, channels
212
+ )
213
+ return duration
214
+
215
+
216
+ def list_input_devices() -> list[dict[str, Any]]:
217
+ """
218
+ Return a list of available input devices with basic metadata.
219
+
220
+ Each entry contains:
221
+ - index: device index
222
+ - name: device name
223
+ - max_input_channels: maximum input channels supported
224
+ - default_samplerate: default sample rate (may be None)
225
+ """
226
+ devices = sd.query_devices()
227
+ results: list[dict[str, Any]] = []
228
+ for idx, dev in enumerate(devices):
229
+ try:
230
+ if int(dev.get("max_input_channels", 0)) > 0:
231
+ results.append(
232
+ {
233
+ "index": idx,
234
+ "name": dev.get("name"),
235
+ "max_input_channels": dev.get("max_input_channels"),
236
+ "default_samplerate": dev.get("default_samplerate"),
237
+ }
238
+ )
239
+ except Exception:
240
+ # Be defensive: ignore any malformed device entries
241
+ continue
242
+ return results
243
+
244
+
245
+ def default_input_device_index() -> int | None:
246
+ """
247
+ Return the default input device index if available, otherwise None.
248
+ """
249
+ try:
250
+ defaults = sd.default.device # (input, output)
251
+ if isinstance(defaults, (list, tuple)) and len(defaults) >= 1:
252
+ idx = defaults[0]
253
+ return int(idx) if idx is not None else None
254
+ except Exception:
255
+ return None
256
+ return None
svx/core/clipboard.py ADDED
@@ -0,0 +1,122 @@
1
+ """
2
+ Clipboard helper for SuperVoxtral.
3
+
4
+ Provides a small, dependency-light utility to copy text to the system clipboard.
5
+
6
+ Strategy:
7
+ - Primary: use `pyperclip` if available (cross-platform).
8
+ - Fallback (macOS): use `pbcopy` via subprocess.
9
+ - If neither is available the function will raise a RuntimeError.
10
+
11
+ This module intentionally keeps a very small surface area (single helper function)
12
+ so it can be imported and used from the CLI with minimal coupling.
13
+
14
+ Usage:
15
+ from svx.core.clipboard import copy_to_clipboard
16
+
17
+ try:
18
+ copy_to_clipboard(text)
19
+ except RuntimeError:
20
+ logging.warning("Failed to copy to clipboard")
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import shlex
27
+ import subprocess
28
+ from typing import Final
29
+
30
+ __all__ = ["copy_to_clipboard", "ClipboardError"]
31
+
32
+
33
+ class ClipboardError(RuntimeError):
34
+ """Raised when copying to the clipboard fails in an expected way."""
35
+
36
+
37
+ _PBCOPY_CMD: Final[str] = "pbcopy"
38
+
39
+
40
+ def _try_pyperclip(text: str) -> None:
41
+ """
42
+ Attempt to copy `text` using the pyperclip library.
43
+
44
+ Raises:
45
+ ClipboardError: if pyperclip is installed but copying fails.
46
+ ImportError: if pyperclip is not installed.
47
+ """
48
+ try:
49
+ import pyperclip
50
+ except Exception as e:
51
+ # Propagate ImportError-like behavior to allow fallback
52
+ raise ImportError("pyperclip not available") from e
53
+
54
+ try:
55
+ pyperclip.copy(text)
56
+ except Exception as e:
57
+ raise ClipboardError("pyperclip.copy failed") from e
58
+
59
+
60
+ def _try_pbcopy(text: str) -> None:
61
+ """
62
+ Attempt to copy `text` using the macOS `pbcopy` command.
63
+
64
+ Raises:
65
+ ClipboardError: if pbcopy is not available or the subprocess fails.
66
+ """
67
+ # Use shlex to be defensive, but `pbcopy` reads from stdin so we don't pass args.
68
+ cmd = shlex.split(_PBCOPY_CMD)
69
+ try:
70
+ # On macOS, pbcopy reads from stdin.
71
+ subprocess.run(cmd, input=text, text=True, capture_output=True, check=True)
72
+ except FileNotFoundError as e:
73
+ raise ClipboardError("pbcopy not found on PATH") from e
74
+ except subprocess.CalledProcessError as e:
75
+ logging.debug("pbcopy stderr: %s", e.stderr)
76
+ raise ClipboardError("pbcopy failed") from e
77
+ except Exception as e:
78
+ raise ClipboardError("Unexpected error when running pbcopy") from e
79
+
80
+
81
+ def copy_to_clipboard(text: str) -> None:
82
+ """
83
+ Copy the given text to the system clipboard.
84
+
85
+ Attempts pyperclip first (recommended). If pyperclip is not installed,
86
+ falls back to `pbcopy` (macOS). If all methods fail, raises ClipboardError.
87
+
88
+ Args:
89
+ text: Text to copy. Non-str inputs will be coerced via str().
90
+
91
+ Raises:
92
+ ClipboardError: if copying fails or no supported method is available.
93
+ """
94
+ if text is None:
95
+ text = ""
96
+
97
+ if not isinstance(text, str):
98
+ text = str(text)
99
+
100
+ # 1) Try pyperclip (preferred)
101
+ try:
102
+ _try_pyperclip(text)
103
+ logging.debug("Copied text to clipboard via pyperclip")
104
+ return
105
+ except ImportError:
106
+ logging.debug("pyperclip not available, trying pbcopy fallback")
107
+ except ClipboardError as e:
108
+ # pyperclip import succeeded but copy failed; try fallback before giving up.
109
+ logging.warning("pyperclip.copy failed: %s. Trying fallback.", e)
110
+
111
+ # 2) Fallback: pbcopy (macOS)
112
+ try:
113
+ _try_pbcopy(text)
114
+ logging.debug("Copied text to clipboard via pbcopy")
115
+ return
116
+ except ClipboardError as e:
117
+ logging.debug("pbcopy fallback failed: %s", e)
118
+
119
+ # No method succeeded
120
+ raise ClipboardError(
121
+ "Failed to copy text to clipboard: no supported method succeeded (pyperclip / pbcopy)."
122
+ )