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.
- supervoxtral-0.1.0.dist-info/METADATA +23 -0
- supervoxtral-0.1.0.dist-info/RECORD +18 -0
- supervoxtral-0.1.0.dist-info/WHEEL +4 -0
- supervoxtral-0.1.0.dist-info/entry_points.txt +2 -0
- supervoxtral-0.1.0.dist-info/licenses/LICENSE +21 -0
- svx/__init__.py +28 -0
- svx/cli.py +264 -0
- svx/core/__init__.py +92 -0
- svx/core/audio.py +256 -0
- svx/core/clipboard.py +122 -0
- svx/core/config.py +400 -0
- svx/core/pipeline.py +260 -0
- svx/core/prompt.py +165 -0
- svx/core/storage.py +118 -0
- svx/providers/__init__.py +88 -0
- svx/providers/base.py +83 -0
- svx/providers/mistral.py +189 -0
- svx/ui/qt_app.py +491 -0
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
|
+
)
|