vocal-cli 0.3.4__tar.gz → 0.3.5__tar.gz
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.
- {vocal_cli-0.3.4 → vocal_cli-0.3.5}/PKG-INFO +6 -2
- {vocal_cli-0.3.4 → vocal_cli-0.3.5}/pyproject.toml +6 -2
- {vocal_cli-0.3.4 → vocal_cli-0.3.5}/vocal_cli/__init__.py +1 -1
- vocal_cli-0.3.5/vocal_cli/main.py +865 -0
- vocal_cli-0.3.4/vocal_cli/main.py +0 -207
- {vocal_cli-0.3.4 → vocal_cli-0.3.5}/.gitignore +0 -0
- {vocal_cli-0.3.4 → vocal_cli-0.3.5}/MANIFEST.in +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vocal-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: CLI tool for Vocal - Ollama-style Voice Model Management
|
|
5
5
|
Project-URL: Homepage, https://github.com/niradler/vocal
|
|
6
6
|
Project-URL: Documentation, https://github.com/niradler/vocal#readme
|
|
@@ -18,7 +18,11 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
19
|
Classifier: Topic :: Utilities
|
|
20
20
|
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: numpy>=2.4.2
|
|
21
22
|
Requires-Dist: rich>=13.0.0
|
|
23
|
+
Requires-Dist: sounddevice>=0.5.5
|
|
22
24
|
Requires-Dist: typer>=0.12.0
|
|
23
25
|
Requires-Dist: uvicorn>=0.31.0
|
|
24
|
-
Requires-Dist: vocal-
|
|
26
|
+
Requires-Dist: vocal-core>=0.3.5
|
|
27
|
+
Requires-Dist: vocal-sdk>=0.3.5
|
|
28
|
+
Requires-Dist: websockets>=12.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "vocal-cli"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.5"
|
|
4
4
|
description = "CLI tool for Vocal - Ollama-style Voice Model Management"
|
|
5
5
|
requires-python = ">=3.11"
|
|
6
6
|
license = { text = "SSPL-1.0" }
|
|
@@ -19,10 +19,14 @@ classifiers = [
|
|
|
19
19
|
"Topic :: Utilities",
|
|
20
20
|
]
|
|
21
21
|
dependencies = [
|
|
22
|
-
"vocal-
|
|
22
|
+
"vocal-core>=0.3.5",
|
|
23
|
+
"vocal-sdk>=0.3.5",
|
|
23
24
|
"typer>=0.12.0",
|
|
24
25
|
"rich>=13.0.0",
|
|
25
26
|
"uvicorn>=0.31.0",
|
|
27
|
+
"sounddevice>=0.5.5",
|
|
28
|
+
"numpy>=2.4.2",
|
|
29
|
+
"websockets>=12.0",
|
|
26
30
|
]
|
|
27
31
|
|
|
28
32
|
[project.urls]
|
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import queue
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import wave
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import numpy as np
|
|
14
|
+
import sounddevice as sd
|
|
15
|
+
import typer
|
|
16
|
+
import websockets
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from vocal_core.config import vocal_settings
|
|
21
|
+
from vocal_sdk import VocalClient
|
|
22
|
+
from vocal_sdk.api.models import (
|
|
23
|
+
delete_model_v1_models_model_id_delete,
|
|
24
|
+
download_model_v1_models_model_id_download_post,
|
|
25
|
+
list_models_v1_models_get,
|
|
26
|
+
)
|
|
27
|
+
from vocal_sdk.api.transcription import (
|
|
28
|
+
create_transcription_v1_audio_transcriptions_post,
|
|
29
|
+
create_translation_v1_audio_translations_post,
|
|
30
|
+
)
|
|
31
|
+
from vocal_sdk.models import (
|
|
32
|
+
BodyCreateTranscriptionV1AudioTranscriptionsPost,
|
|
33
|
+
BodyCreateTranslationV1AudioTranslationsPost,
|
|
34
|
+
TranscriptionFormat,
|
|
35
|
+
)
|
|
36
|
+
from vocal_sdk.types import UNSET, File, Unset
|
|
37
|
+
|
|
38
|
+
app = typer.Typer(
|
|
39
|
+
name="vocal",
|
|
40
|
+
help="Vocal - Generic Speech AI Platform CLI",
|
|
41
|
+
no_args_is_help=True,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
models_app = typer.Typer(help="Model management commands")
|
|
45
|
+
app.add_typer(models_app, name="models")
|
|
46
|
+
|
|
47
|
+
console = Console()
|
|
48
|
+
|
|
49
|
+
_SAMPLE_RATE = vocal_settings.STT_SAMPLE_RATE
|
|
50
|
+
_FRAME_SIZE = vocal_settings.AUDIO_FRAME_SIZE
|
|
51
|
+
_CHANNELS = vocal_settings.AUDIO_CHANNELS
|
|
52
|
+
_PLAYBACK_COOLDOWN = vocal_settings.PLAYBACK_COOLDOWN
|
|
53
|
+
_CALIB_FRAMES = 15
|
|
54
|
+
|
|
55
|
+
_print_lock = threading.Lock()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _make_client(api_url: str) -> VocalClient:
|
|
59
|
+
return VocalClient(base_url=api_url, timeout=httpx.Timeout(300.0), raise_on_unexpected_status=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def run(
|
|
64
|
+
audio_file: Path = typer.Argument(..., help="Path to audio file to transcribe"),
|
|
65
|
+
model: str = typer.Option(
|
|
66
|
+
"Systran/faster-whisper-tiny",
|
|
67
|
+
"--model",
|
|
68
|
+
"-m",
|
|
69
|
+
help="Model to use for transcription",
|
|
70
|
+
),
|
|
71
|
+
language: str | None = typer.Option(None, "--language", "-l", help="Language code (e.g., 'en', 'es')"),
|
|
72
|
+
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text, json, srt, vtt"),
|
|
73
|
+
api_url: str = typer.Option("http://localhost:8000", "--api-url", help="Vocal API URL"),
|
|
74
|
+
):
|
|
75
|
+
"""Transcribe audio file to text"""
|
|
76
|
+
if not audio_file.exists():
|
|
77
|
+
console.print(f"[red]Error:[/red] File not found: {audio_file}")
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
vc = _make_client(api_url)
|
|
82
|
+
console.print("Transcribing audio...")
|
|
83
|
+
|
|
84
|
+
with open(audio_file, "rb") as fobj:
|
|
85
|
+
body = BodyCreateTranscriptionV1AudioTranscriptionsPost(
|
|
86
|
+
file=File(payload=fobj, file_name=audio_file.name),
|
|
87
|
+
model=model,
|
|
88
|
+
language=language if language is not None else UNSET,
|
|
89
|
+
response_format=TranscriptionFormat.JSON,
|
|
90
|
+
)
|
|
91
|
+
result = create_transcription_v1_audio_transcriptions_post.sync(client=vc, body=body)
|
|
92
|
+
|
|
93
|
+
if result is None:
|
|
94
|
+
console.print("[red]Error:[/red] Transcription failed - no response")
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
|
|
97
|
+
if output_format == "text":
|
|
98
|
+
console.print(result.text)
|
|
99
|
+
elif output_format == "json":
|
|
100
|
+
console.print_json(json.dumps(result.to_dict()))
|
|
101
|
+
elif output_format in ("srt", "vtt"):
|
|
102
|
+
segs = [] if isinstance(result.segments, Unset) or result.segments is None else result.segments
|
|
103
|
+
if output_format == "vtt":
|
|
104
|
+
console.print("WEBVTT\n")
|
|
105
|
+
for seg in segs:
|
|
106
|
+
if output_format == "srt":
|
|
107
|
+
console.print(f"{seg.id + 1}")
|
|
108
|
+
console.print(f"{_format_timestamp(seg.start)} --> {_format_timestamp(seg.end)}")
|
|
109
|
+
else:
|
|
110
|
+
console.print(f"{_format_timestamp(seg.start, use_comma=False)} --> {_format_timestamp(seg.end, use_comma=False)}")
|
|
111
|
+
console.print(seg.text)
|
|
112
|
+
console.print()
|
|
113
|
+
|
|
114
|
+
except Exception as e:
|
|
115
|
+
console.print(f"[red]Error:[/red] {str(e)}")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@models_app.command("list")
|
|
120
|
+
def models_list(
|
|
121
|
+
task: str | None = typer.Option(None, "--task", "-t", help="Filter by task: stt, tts"),
|
|
122
|
+
api_url: str = typer.Option("http://localhost:8000", "--api-url", help="Vocal API URL"),
|
|
123
|
+
):
|
|
124
|
+
"""List all available models"""
|
|
125
|
+
try:
|
|
126
|
+
vc = _make_client(api_url)
|
|
127
|
+
response = list_models_v1_models_get.sync(client=vc, task=task if task is not None else UNSET)
|
|
128
|
+
|
|
129
|
+
if response is None:
|
|
130
|
+
console.print("[red]Error:[/red] Failed to list models")
|
|
131
|
+
raise typer.Exit(1)
|
|
132
|
+
|
|
133
|
+
table = Table(title="Vocal Models")
|
|
134
|
+
table.add_column("Model ID", style="cyan")
|
|
135
|
+
table.add_column("Task", style="magenta")
|
|
136
|
+
table.add_column("Status", style="green")
|
|
137
|
+
table.add_column("Size", style="yellow")
|
|
138
|
+
|
|
139
|
+
for m in response.models:
|
|
140
|
+
status_val = m.status.value
|
|
141
|
+
status_color = {
|
|
142
|
+
"available": "green",
|
|
143
|
+
"downloading": "yellow",
|
|
144
|
+
"not_downloaded": "red",
|
|
145
|
+
}.get(status_val, "white")
|
|
146
|
+
size = str(m.size_readable) if not isinstance(m.size_readable, Unset) else "N/A"
|
|
147
|
+
table.add_row(
|
|
148
|
+
m.id,
|
|
149
|
+
m.task.value,
|
|
150
|
+
f"[{status_color}]{status_val}[/{status_color}]",
|
|
151
|
+
size,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
console.print(table)
|
|
155
|
+
console.print(f"\nTotal models: {response.total}")
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
console.print(f"[red]Error:[/red] {str(e)}")
|
|
159
|
+
raise typer.Exit(1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@models_app.command("pull")
|
|
163
|
+
def models_pull(
|
|
164
|
+
model_id: str = typer.Argument(..., help="Model ID to download"),
|
|
165
|
+
api_url: str = typer.Option("http://localhost:8000", "--api-url", help="Vocal API URL"),
|
|
166
|
+
):
|
|
167
|
+
"""Download a model (Ollama-style pull)"""
|
|
168
|
+
try:
|
|
169
|
+
vc = _make_client(api_url)
|
|
170
|
+
console.print(f"Downloading {model_id}...")
|
|
171
|
+
result = download_model_v1_models_model_id_download_post.sync(model_id=model_id, client=vc)
|
|
172
|
+
console.print(f"[green]Successfully downloaded:[/green] {model_id}")
|
|
173
|
+
if result is not None:
|
|
174
|
+
console.print(f"Status: {result.status.value}")
|
|
175
|
+
except Exception as e:
|
|
176
|
+
console.print(f"[red]Error:[/red] {str(e)}")
|
|
177
|
+
raise typer.Exit(1)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@models_app.command("delete")
|
|
181
|
+
def models_delete(
|
|
182
|
+
model_id: str = typer.Argument(..., help="Model ID to delete"),
|
|
183
|
+
api_url: str = typer.Option("http://localhost:8000", "--api-url", help="Vocal API URL"),
|
|
184
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
|
|
185
|
+
):
|
|
186
|
+
"""Delete a downloaded model"""
|
|
187
|
+
if not force:
|
|
188
|
+
confirm = typer.confirm(f"Are you sure you want to delete {model_id}?")
|
|
189
|
+
if not confirm:
|
|
190
|
+
console.print("Cancelled")
|
|
191
|
+
raise typer.Exit(0)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
vc = _make_client(api_url)
|
|
195
|
+
delete_model_v1_models_model_id_delete.sync(model_id=model_id, client=vc)
|
|
196
|
+
console.print(f"[green]Successfully deleted:[/green] {model_id}")
|
|
197
|
+
except Exception as e:
|
|
198
|
+
console.print(f"[red]Error:[/red] {str(e)}")
|
|
199
|
+
raise typer.Exit(1)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command()
|
|
203
|
+
def serve(
|
|
204
|
+
host: str = typer.Option("0.0.0.0", "--host", "-h", help="Host to bind to"),
|
|
205
|
+
port: int = typer.Option(8000, "--port", "-p", help="Port to bind to"),
|
|
206
|
+
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
|
|
207
|
+
):
|
|
208
|
+
"""Start the Vocal API server"""
|
|
209
|
+
import uvicorn
|
|
210
|
+
|
|
211
|
+
console.print("[green]Starting Vocal API server...[/green]")
|
|
212
|
+
console.print(f"API: http://{host}:{port}")
|
|
213
|
+
console.print(f"Docs: http://{host}:{port}/docs")
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
uvicorn.run("vocal_api.main:app", host=host, port=port, reload=reload)
|
|
217
|
+
except KeyboardInterrupt:
|
|
218
|
+
console.print("\n[yellow]Server stopped[/yellow]")
|
|
219
|
+
except Exception as e:
|
|
220
|
+
console.print(f"[red]Error:[/red] {str(e)}")
|
|
221
|
+
raise typer.Exit(1)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _input_devices() -> list[dict]:
|
|
225
|
+
"""Return all input-capable audio devices with their index, name, channels, and default flag."""
|
|
226
|
+
default_idx = sd.default.device[0]
|
|
227
|
+
devices = []
|
|
228
|
+
for idx, dev in enumerate(sd.query_devices()):
|
|
229
|
+
if dev["max_input_channels"] > 0:
|
|
230
|
+
devices.append(
|
|
231
|
+
{
|
|
232
|
+
"index": idx,
|
|
233
|
+
"name": dev["name"],
|
|
234
|
+
"channels": dev["max_input_channels"],
|
|
235
|
+
"sample_rate": int(dev["default_samplerate"]),
|
|
236
|
+
"is_default": idx == default_idx,
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
return devices
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _resolve_device(device: str | None) -> int | None:
|
|
243
|
+
"""Resolve --device value to a sounddevice index (int) or None for OS default."""
|
|
244
|
+
if device is None:
|
|
245
|
+
return None
|
|
246
|
+
if device.isdigit():
|
|
247
|
+
return int(device)
|
|
248
|
+
for dev in _input_devices():
|
|
249
|
+
if device.lower() in dev["name"].lower():
|
|
250
|
+
return dev["index"]
|
|
251
|
+
raise ValueError(f"No input device matching '{device}'. Run `vocal devices` to list available inputs.")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _print_devices_table() -> None:
|
|
255
|
+
devs = _input_devices()
|
|
256
|
+
if not devs:
|
|
257
|
+
console.print("[red]No input devices found.[/red]")
|
|
258
|
+
return
|
|
259
|
+
table = Table(title="Available Input Devices")
|
|
260
|
+
table.add_column("#", style="cyan", justify="right")
|
|
261
|
+
table.add_column("Name", style="white")
|
|
262
|
+
table.add_column("Ch", justify="right")
|
|
263
|
+
table.add_column("Default Rate", justify="right", style="yellow")
|
|
264
|
+
table.add_column("", style="green")
|
|
265
|
+
for dev in devs:
|
|
266
|
+
table.add_row(
|
|
267
|
+
str(dev["index"]),
|
|
268
|
+
dev["name"],
|
|
269
|
+
str(dev["channels"]),
|
|
270
|
+
f"{dev['sample_rate']} Hz",
|
|
271
|
+
"* default" if dev["is_default"] else "",
|
|
272
|
+
)
|
|
273
|
+
console.print(table)
|
|
274
|
+
console.print('\nUse [cyan]--device <#>[/cyan] or [cyan]--device "name"[/cyan] to select.')
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.command()
|
|
278
|
+
def devices() -> None:
|
|
279
|
+
"""List available audio input devices"""
|
|
280
|
+
_print_devices_table()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _pcm_to_wav(frames: list) -> io.BytesIO:
|
|
284
|
+
buf = io.BytesIO()
|
|
285
|
+
with wave.open(buf, "wb") as wf:
|
|
286
|
+
wf.setnchannels(_CHANNELS)
|
|
287
|
+
wf.setsampwidth(2)
|
|
288
|
+
wf.setframerate(_SAMPLE_RATE)
|
|
289
|
+
wf.writeframes(np.concatenate(frames, axis=0).tobytes())
|
|
290
|
+
buf.seek(0)
|
|
291
|
+
return buf
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _rms_energy(frame) -> float:
|
|
295
|
+
return float(np.sqrt(np.mean(frame.astype(np.float32) ** 2)))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _calibrate_from_frames(frames: list) -> float:
|
|
299
|
+
energies = [_rms_energy(f) for f in frames]
|
|
300
|
+
noise_floor = float(np.mean(energies)) if energies else 0.0
|
|
301
|
+
return max(noise_floor * 4.0, 50.0)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _status_line(energy: float, threshold: float) -> str:
|
|
305
|
+
width = 20
|
|
306
|
+
filled = min(int(width * energy / max(threshold * 2, 1)), width)
|
|
307
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
308
|
+
state = "speaking" if energy >= threshold else "silent "
|
|
309
|
+
return f"\r [{bar}] {energy:6.0f} / {threshold:.0f} {state} "
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _schedule_flush(
|
|
313
|
+
frames: list,
|
|
314
|
+
has_speech: bool,
|
|
315
|
+
vc,
|
|
316
|
+
model: str,
|
|
317
|
+
language: str | None,
|
|
318
|
+
task: str,
|
|
319
|
+
verbose: bool,
|
|
320
|
+
) -> threading.Thread:
|
|
321
|
+
t = threading.Thread(target=_flush_buffer, args=(frames, has_speech, vc, model, language, task, verbose), daemon=True)
|
|
322
|
+
t.start()
|
|
323
|
+
return t
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _await_pending(pending: threading.Thread | None, timeout: float = 5.0) -> None:
|
|
327
|
+
if pending and pending.is_alive():
|
|
328
|
+
pending.join(timeout=timeout)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _run_stream(
|
|
332
|
+
q: queue.SimpleQueue,
|
|
333
|
+
silence_frames_needed: int,
|
|
334
|
+
max_chunk_duration: float,
|
|
335
|
+
user_threshold: float | None,
|
|
336
|
+
vc,
|
|
337
|
+
model: str,
|
|
338
|
+
language: str | None,
|
|
339
|
+
task: str,
|
|
340
|
+
verbose: bool,
|
|
341
|
+
) -> None:
|
|
342
|
+
calib_buf: list = []
|
|
343
|
+
threshold = user_threshold or 0.0
|
|
344
|
+
calibrated = user_threshold is not None
|
|
345
|
+
buffer: list = []
|
|
346
|
+
silence_count = 0
|
|
347
|
+
has_speech = False
|
|
348
|
+
pending: threading.Thread | None = None
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
while True:
|
|
352
|
+
try:
|
|
353
|
+
frame = q.get(timeout=0.2)
|
|
354
|
+
except queue.Empty:
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
if not calibrated:
|
|
358
|
+
calib_buf.append(frame)
|
|
359
|
+
sys.stdout.write(f"\r Calibrating mic... {len(calib_buf)}/{_CALIB_FRAMES} frames ")
|
|
360
|
+
sys.stdout.flush()
|
|
361
|
+
if len(calib_buf) >= _CALIB_FRAMES:
|
|
362
|
+
threshold = _calibrate_from_frames(calib_buf)
|
|
363
|
+
calibrated = True
|
|
364
|
+
sys.stdout.write(f"\r Threshold set: {threshold:.0f} (speak now) \n")
|
|
365
|
+
sys.stdout.flush()
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
buffer.append(frame)
|
|
369
|
+
energy = _rms_energy(frame)
|
|
370
|
+
total_duration = len(buffer) * _FRAME_SIZE / _SAMPLE_RATE
|
|
371
|
+
|
|
372
|
+
with _print_lock:
|
|
373
|
+
sys.stdout.write(_status_line(energy, threshold))
|
|
374
|
+
sys.stdout.flush()
|
|
375
|
+
|
|
376
|
+
if energy >= threshold:
|
|
377
|
+
has_speech = True
|
|
378
|
+
silence_count = 0
|
|
379
|
+
else:
|
|
380
|
+
silence_count += 1
|
|
381
|
+
|
|
382
|
+
chunk_done = total_duration >= max_chunk_duration or (has_speech and silence_count >= silence_frames_needed)
|
|
383
|
+
if chunk_done:
|
|
384
|
+
with _print_lock:
|
|
385
|
+
sys.stdout.write("\r" + " " * 60 + "\r")
|
|
386
|
+
sys.stdout.flush()
|
|
387
|
+
pending = _schedule_flush(buffer, has_speech, vc, model, language, task, verbose)
|
|
388
|
+
buffer, silence_count, has_speech = [], 0, False
|
|
389
|
+
|
|
390
|
+
except KeyboardInterrupt:
|
|
391
|
+
with _print_lock:
|
|
392
|
+
sys.stdout.write("\r" + " " * 60 + "\r")
|
|
393
|
+
sys.stdout.flush()
|
|
394
|
+
_await_pending(pending, timeout=5.0)
|
|
395
|
+
raise
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _flush_buffer(
|
|
399
|
+
frames: list,
|
|
400
|
+
has_speech: bool,
|
|
401
|
+
vc: VocalClient,
|
|
402
|
+
model: str,
|
|
403
|
+
language: str | None,
|
|
404
|
+
task: str,
|
|
405
|
+
verbose: bool,
|
|
406
|
+
) -> None:
|
|
407
|
+
if not frames or not has_speech:
|
|
408
|
+
return
|
|
409
|
+
audio_secs = len(frames) * _FRAME_SIZE / _SAMPLE_RATE
|
|
410
|
+
with _print_lock:
|
|
411
|
+
sys.stdout.write("\r" + " " * 60 + "\r")
|
|
412
|
+
sys.stdout.write(f" [dim]→ {audio_secs:.1f}s[/dim] ")
|
|
413
|
+
sys.stdout.flush()
|
|
414
|
+
wav_buf = _pcm_to_wav(frames)
|
|
415
|
+
t0 = time.monotonic()
|
|
416
|
+
try:
|
|
417
|
+
if task == "translate":
|
|
418
|
+
body = BodyCreateTranslationV1AudioTranslationsPost(
|
|
419
|
+
file=File(payload=wav_buf, file_name="mic.wav", mime_type="audio/wav"),
|
|
420
|
+
model=model,
|
|
421
|
+
)
|
|
422
|
+
result = create_translation_v1_audio_translations_post.sync(client=vc, body=body)
|
|
423
|
+
else:
|
|
424
|
+
body = BodyCreateTranscriptionV1AudioTranscriptionsPost(
|
|
425
|
+
file=File(payload=wav_buf, file_name="mic.wav", mime_type="audio/wav"),
|
|
426
|
+
model=model,
|
|
427
|
+
language=language if language is not None else UNSET,
|
|
428
|
+
response_format=TranscriptionFormat.JSON,
|
|
429
|
+
)
|
|
430
|
+
result = create_transcription_v1_audio_transcriptions_post.sync(client=vc, body=body)
|
|
431
|
+
elapsed = time.monotonic() - t0
|
|
432
|
+
with _print_lock:
|
|
433
|
+
sys.stdout.write("\r" + " " * 60 + "\r")
|
|
434
|
+
sys.stdout.flush()
|
|
435
|
+
if result and result.text.strip():
|
|
436
|
+
suffix = f" [dim]({elapsed:.1f}s)[/dim]" if verbose else ""
|
|
437
|
+
console.print(f"[cyan]>[/cyan] {result.text.strip()}{suffix}")
|
|
438
|
+
except Exception as e:
|
|
439
|
+
elapsed = time.monotonic() - t0
|
|
440
|
+
with _print_lock:
|
|
441
|
+
sys.stdout.write("\r" + " " * 60 + "\r")
|
|
442
|
+
sys.stdout.flush()
|
|
443
|
+
console.print(f"[red]error[/red] after {elapsed:.1f}s: {e}")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@app.command()
|
|
447
|
+
def listen(
|
|
448
|
+
model: str = typer.Option(
|
|
449
|
+
"Systran/faster-whisper-tiny",
|
|
450
|
+
"--model",
|
|
451
|
+
"-m",
|
|
452
|
+
help="STT model to use",
|
|
453
|
+
),
|
|
454
|
+
language: str | None = typer.Option(None, "--language", "-l", help="Language code (e.g. 'en'). Auto-detect if omitted."),
|
|
455
|
+
task: str = typer.Option("transcribe", "--task", help="'transcribe' or 'translate' (translate any language to English)"),
|
|
456
|
+
device: str | None = typer.Option(None, "--device", "-d", help="Input device index or name substring. Run `vocal devices` to list."),
|
|
457
|
+
list_devices: bool = typer.Option(False, "--list-devices", help="List available input devices and exit"),
|
|
458
|
+
silence_threshold: float | None = typer.Option(None, "--silence-threshold", help="RMS energy threshold. Auto-calibrated from mic noise floor if omitted."),
|
|
459
|
+
silence_duration: float = typer.Option(1.5, "--silence-duration", help="Seconds of silence that triggers chunk send"),
|
|
460
|
+
max_chunk_duration: float = typer.Option(10.0, "--max-chunk-duration", help="Max seconds of audio before forced send"),
|
|
461
|
+
api_url: str = typer.Option("http://localhost:8000", "--api-url", help="Vocal API URL"),
|
|
462
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show chunk send timing and API latency"),
|
|
463
|
+
):
|
|
464
|
+
"""Listen to the microphone and transcribe speech in real-time (ASR streaming mode)"""
|
|
465
|
+
if list_devices:
|
|
466
|
+
_print_devices_table()
|
|
467
|
+
raise typer.Exit(0)
|
|
468
|
+
|
|
469
|
+
try:
|
|
470
|
+
device_idx = _resolve_device(device)
|
|
471
|
+
except ValueError as e:
|
|
472
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
473
|
+
raise typer.Exit(1)
|
|
474
|
+
|
|
475
|
+
active_device = sd.query_devices(device_idx if device_idx is not None else sd.default.device[0])
|
|
476
|
+
device_label = f"[dim]{active_device['name']}[/dim]"
|
|
477
|
+
silence_frames_needed = int(silence_duration * _SAMPLE_RATE / _FRAME_SIZE)
|
|
478
|
+
|
|
479
|
+
probe = VocalClient(base_url=api_url, timeout=httpx.Timeout(5.0), raise_on_unexpected_status=False)
|
|
480
|
+
console.print("[dim]Checking model status...[/dim]", end=" ")
|
|
481
|
+
try:
|
|
482
|
+
models_result = list_models_v1_models_get.sync(client=probe)
|
|
483
|
+
model_info = next((m for m in (models_result.models if models_result else []) if m.id == model), None)
|
|
484
|
+
if model_info is None:
|
|
485
|
+
console.print(f"[red]not found[/red]\nRun `vocal models pull {model}` first.")
|
|
486
|
+
raise typer.Exit(1)
|
|
487
|
+
status = str(model_info.status).lower()
|
|
488
|
+
if "available" in status:
|
|
489
|
+
console.print("[green]ready[/green]")
|
|
490
|
+
else:
|
|
491
|
+
console.print(f"[yellow]{status}[/yellow] — first transcription may be slow")
|
|
492
|
+
except typer.Exit:
|
|
493
|
+
raise
|
|
494
|
+
except Exception:
|
|
495
|
+
console.print("[red]unreachable[/red]")
|
|
496
|
+
console.print("API server is not running. Start it with: [cyan]vocal serve[/cyan]")
|
|
497
|
+
raise typer.Exit(1)
|
|
498
|
+
|
|
499
|
+
vc = VocalClient(base_url=api_url, timeout=httpx.Timeout(60.0), raise_on_unexpected_status=False)
|
|
500
|
+
threshold_hint = f"threshold=[cyan]{silence_threshold:.0f}[/cyan]" if silence_threshold is not None else "threshold=[cyan]auto[/cyan]"
|
|
501
|
+
console.print(f"[green]Listening...[/green] model=[cyan]{model}[/cyan] task=[cyan]{task}[/cyan] device={device_label} {threshold_hint} Ctrl+C to stop\n")
|
|
502
|
+
|
|
503
|
+
audio_queue: queue.SimpleQueue = queue.SimpleQueue()
|
|
504
|
+
|
|
505
|
+
def _audio_callback(indata, _frames, _ts, _status):
|
|
506
|
+
audio_queue.put(indata.copy())
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
with sd.InputStream(
|
|
510
|
+
samplerate=_SAMPLE_RATE,
|
|
511
|
+
channels=_CHANNELS,
|
|
512
|
+
dtype="int16",
|
|
513
|
+
blocksize=_FRAME_SIZE,
|
|
514
|
+
device=device_idx,
|
|
515
|
+
callback=_audio_callback,
|
|
516
|
+
):
|
|
517
|
+
_run_stream(audio_queue, silence_frames_needed, max_chunk_duration, silence_threshold, vc, model, language, task, verbose)
|
|
518
|
+
except KeyboardInterrupt:
|
|
519
|
+
console.print("\n[yellow]Stopped.[/yellow]")
|
|
520
|
+
except Exception as e:
|
|
521
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
522
|
+
raise typer.Exit(1)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
_DEFAULT_SYSTEM_PROMPT = vocal_settings.CHAT_SYSTEM_PROMPT
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _output_devices() -> list[dict]:
|
|
529
|
+
seen: set[str] = set()
|
|
530
|
+
devices = []
|
|
531
|
+
default_idx = sd.default.device[1]
|
|
532
|
+
for idx, dev in enumerate(sd.query_devices()):
|
|
533
|
+
if dev["max_output_channels"] > 0 and dev["name"] not in seen:
|
|
534
|
+
seen.add(dev["name"])
|
|
535
|
+
devices.append(
|
|
536
|
+
{
|
|
537
|
+
"index": idx,
|
|
538
|
+
"name": dev["name"],
|
|
539
|
+
"channels": dev["max_output_channels"],
|
|
540
|
+
"sample_rate": int(dev["default_samplerate"]),
|
|
541
|
+
"is_default": idx == default_idx,
|
|
542
|
+
}
|
|
543
|
+
)
|
|
544
|
+
return devices
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _resolve_output_device(device: str | None) -> int | None:
|
|
548
|
+
if device is None:
|
|
549
|
+
return None
|
|
550
|
+
if device.isdigit():
|
|
551
|
+
return int(device)
|
|
552
|
+
for dev in _output_devices():
|
|
553
|
+
if device.lower() in dev["name"].lower():
|
|
554
|
+
return dev["index"]
|
|
555
|
+
raise ValueError(f"No output device matching '{device}'. Run `vocal output-devices` to list available outputs.")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@app.command("output-devices")
|
|
559
|
+
def output_devices() -> None:
|
|
560
|
+
"""List available audio output devices"""
|
|
561
|
+
devs = _output_devices()
|
|
562
|
+
if not devs:
|
|
563
|
+
console.print("[red]No output devices found.[/red]")
|
|
564
|
+
return
|
|
565
|
+
table = Table(title="Available Output Devices")
|
|
566
|
+
table.add_column("#", style="cyan", justify="right")
|
|
567
|
+
table.add_column("Name", style="white")
|
|
568
|
+
table.add_column("Ch", justify="right")
|
|
569
|
+
table.add_column("Default Rate", justify="right", style="yellow")
|
|
570
|
+
table.add_column("", style="green")
|
|
571
|
+
for dev in devs:
|
|
572
|
+
table.add_row(str(dev["index"]), dev["name"], str(dev["channels"]), f"{dev['sample_rate']} Hz", "* default" if dev["is_default"] else "")
|
|
573
|
+
console.print(table)
|
|
574
|
+
console.print('\nUse [cyan]--output-device <#>[/cyan] or [cyan]--output-device "name"[/cyan] to select.')
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@app.command()
|
|
578
|
+
def chat(
|
|
579
|
+
model: str = typer.Option(
|
|
580
|
+
"Systran/faster-whisper-tiny",
|
|
581
|
+
"--model",
|
|
582
|
+
"-m",
|
|
583
|
+
help="STT model to use for transcription",
|
|
584
|
+
),
|
|
585
|
+
device: str | None = typer.Option(None, "--device", "-d", help="Input device index or name substring. Run `vocal devices` to list."),
|
|
586
|
+
output_device: str | None = typer.Option(None, "--output-device", "-o", help="Output device index or name. Run `vocal output-devices` to list."),
|
|
587
|
+
language: str | None = typer.Option(None, "--language", "-l", help="Language code (e.g. 'en'). Auto-detect if omitted."),
|
|
588
|
+
system_prompt: str = typer.Option(_DEFAULT_SYSTEM_PROMPT, "--system-prompt", "-s", help="System prompt sent to the LLM."),
|
|
589
|
+
api_url: str = typer.Option("http://localhost:8000", "--api-url", help="Vocal API URL"),
|
|
590
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show transcription and LLM response text"),
|
|
591
|
+
) -> None:
|
|
592
|
+
"""Voice chat: speak to the AI and hear it respond (STT -> LLM -> TTS loop via /v1/realtime)"""
|
|
593
|
+
try:
|
|
594
|
+
device_idx = _resolve_device(device)
|
|
595
|
+
output_device_idx = _resolve_output_device(output_device)
|
|
596
|
+
except ValueError as e:
|
|
597
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
598
|
+
raise typer.Exit(1)
|
|
599
|
+
|
|
600
|
+
ws_url = api_url.replace("http://", "ws://").replace("https://", "wss://")
|
|
601
|
+
active_device = sd.query_devices(device_idx if device_idx is not None else sd.default.device[0])
|
|
602
|
+
device_label = f"[dim]{active_device['name']}[/dim]"
|
|
603
|
+
|
|
604
|
+
console.print(f"[green]Voice chat started[/green] model=[cyan]{model}[/cyan] device={device_label} Ctrl+C to stop\n")
|
|
605
|
+
console.print("[dim]Speak — I'll transcribe, think, and respond with audio.[/dim]\n")
|
|
606
|
+
|
|
607
|
+
try:
|
|
608
|
+
asyncio.run(_chat_async(ws_url, device_idx, output_device_idx, model, language, system_prompt, verbose))
|
|
609
|
+
except KeyboardInterrupt:
|
|
610
|
+
console.print("\n[yellow]Stopped.[/yellow]")
|
|
611
|
+
except Exception as e:
|
|
612
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
613
|
+
raise typer.Exit(1)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _play_pcm16(pcm_bytes: bytes, sample_rate: int = 24000, device: int | None = None) -> None:
|
|
617
|
+
if not pcm_bytes:
|
|
618
|
+
return
|
|
619
|
+
arr = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
|
620
|
+
sd.play(arr, samplerate=sample_rate, device=device)
|
|
621
|
+
sd.wait()
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
_TRACE_SKIP = {"response.output_audio_transcript.delta", "conversation.item.input_audio_transcription.delta"}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _chat_trace(etype: str, event: dict, state: dict) -> None:
|
|
628
|
+
if etype in _TRACE_SKIP:
|
|
629
|
+
return
|
|
630
|
+
extra = ""
|
|
631
|
+
if etype == "response.output_audio.delta":
|
|
632
|
+
extra = f" ({len(event.get('delta', ''))} b64 chars)"
|
|
633
|
+
elif etype == "response.output_audio.done":
|
|
634
|
+
extra = f" (chunks={len(state['audio'])})"
|
|
635
|
+
console.print(f"[dim] [{etype}{extra}][/dim]", markup=False)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
async def _chat_handle_delta(etype: str, event: dict, state: dict, verbose: bool, playing: asyncio.Event) -> None:
|
|
639
|
+
if etype == "conversation.item.input_audio_transcription.delta":
|
|
640
|
+
state["transcript"].append(event.get("delta", ""))
|
|
641
|
+
elif etype == "response.output_audio_transcript.delta":
|
|
642
|
+
delta = event.get("delta", "")
|
|
643
|
+
state["text"].append(delta)
|
|
644
|
+
sys.stdout.write(delta)
|
|
645
|
+
sys.stdout.flush()
|
|
646
|
+
elif etype == "response.output_audio.delta":
|
|
647
|
+
playing.set()
|
|
648
|
+
try:
|
|
649
|
+
state["audio"].append(base64.b64decode(event.get("delta", "")))
|
|
650
|
+
except Exception:
|
|
651
|
+
pass
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
async def _chat_handle_done(
|
|
655
|
+
etype: str,
|
|
656
|
+
event: dict,
|
|
657
|
+
state: dict,
|
|
658
|
+
verbose: bool,
|
|
659
|
+
loop: asyncio.AbstractEventLoop,
|
|
660
|
+
output_device_idx: int | None,
|
|
661
|
+
playing: asyncio.Event,
|
|
662
|
+
) -> None:
|
|
663
|
+
if etype == "conversation.item.input_audio_transcription.completed":
|
|
664
|
+
transcript = event.get("transcript", "").strip() or " ".join(state["transcript"]).strip()
|
|
665
|
+
state["transcript"] = []
|
|
666
|
+
sys.stdout.write("\r" + " " * 60 + "\r")
|
|
667
|
+
if transcript:
|
|
668
|
+
console.print(f"You: {transcript}")
|
|
669
|
+
elif etype == "response.output_audio.done":
|
|
670
|
+
if state["text"]:
|
|
671
|
+
sys.stdout.write("\n")
|
|
672
|
+
sys.stdout.flush()
|
|
673
|
+
if state["audio"]:
|
|
674
|
+
pcm = b"".join(state["audio"])
|
|
675
|
+
await loop.run_in_executor(None, _play_pcm16, pcm, 24000, output_device_idx)
|
|
676
|
+
state["audio"] = []
|
|
677
|
+
state["text"] = []
|
|
678
|
+
await asyncio.sleep(_PLAYBACK_COOLDOWN)
|
|
679
|
+
playing.clear()
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
async def _chat_receiver(ws, output_device_idx: int | None, verbose: bool, loop: asyncio.AbstractEventLoop, stop_event: asyncio.Event, playing: asyncio.Event) -> None:
|
|
683
|
+
state: dict = {"audio": [], "text": [], "transcript": []}
|
|
684
|
+
|
|
685
|
+
async for msg in ws:
|
|
686
|
+
if stop_event.is_set():
|
|
687
|
+
break
|
|
688
|
+
event = json.loads(msg)
|
|
689
|
+
etype = event.get("type", "")
|
|
690
|
+
|
|
691
|
+
if verbose:
|
|
692
|
+
_chat_trace(etype, event, state)
|
|
693
|
+
|
|
694
|
+
if "delta" in etype:
|
|
695
|
+
await _chat_handle_delta(etype, event, state, verbose, playing)
|
|
696
|
+
elif "done" in etype or "completed" in etype:
|
|
697
|
+
await _chat_handle_done(etype, event, state, verbose, loop, output_device_idx, playing)
|
|
698
|
+
elif etype == "input_audio_buffer.speech_started":
|
|
699
|
+
sys.stdout.write("\r[detecting speech...] \r")
|
|
700
|
+
sys.stdout.flush()
|
|
701
|
+
elif etype == "response.done":
|
|
702
|
+
sys.stdout.write("[listening...]\r")
|
|
703
|
+
sys.stdout.flush()
|
|
704
|
+
elif etype == "error":
|
|
705
|
+
console.print(f"\n[red]error:[/red] {event.get('error', {}).get('message', 'unknown')}")
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
async def _chat_async(ws_url: str, device_idx: int | None, output_device_idx: int | None, model: str, language: str | None, system_prompt: str, verbose: bool) -> None:
|
|
709
|
+
audio_q: queue.SimpleQueue = queue.SimpleQueue()
|
|
710
|
+
loop = asyncio.get_running_loop()
|
|
711
|
+
stop_event = asyncio.Event()
|
|
712
|
+
playing = asyncio.Event()
|
|
713
|
+
|
|
714
|
+
def _audio_callback(indata, _frames, _ts, _status) -> None:
|
|
715
|
+
audio_q.put_nowait(indata.copy().tobytes())
|
|
716
|
+
|
|
717
|
+
async def _sender(ws) -> None:
|
|
718
|
+
while not stop_event.is_set():
|
|
719
|
+
try:
|
|
720
|
+
frame = await loop.run_in_executor(None, audio_q.get, True, 0.1)
|
|
721
|
+
if playing.is_set():
|
|
722
|
+
continue
|
|
723
|
+
b64 = base64.b64encode(frame).decode()
|
|
724
|
+
await ws.send(json.dumps({"type": "input_audio_buffer.append", "audio": b64}))
|
|
725
|
+
except queue.Empty:
|
|
726
|
+
continue
|
|
727
|
+
except Exception:
|
|
728
|
+
break
|
|
729
|
+
|
|
730
|
+
async with websockets.connect(f"{ws_url}/v1/realtime", open_timeout=10) as ws:
|
|
731
|
+
await asyncio.wait_for(ws.recv(), timeout=5.0)
|
|
732
|
+
|
|
733
|
+
session_cfg: dict = {
|
|
734
|
+
"type": "realtime",
|
|
735
|
+
"model": model,
|
|
736
|
+
"input_sample_rate": _SAMPLE_RATE,
|
|
737
|
+
"system_prompt": system_prompt,
|
|
738
|
+
}
|
|
739
|
+
if language:
|
|
740
|
+
session_cfg["language"] = language
|
|
741
|
+
await ws.send(json.dumps({"type": "session.update", "session": session_cfg}))
|
|
742
|
+
await asyncio.wait_for(ws.recv(), timeout=5.0)
|
|
743
|
+
|
|
744
|
+
sys.stdout.write("[listening...] \r")
|
|
745
|
+
sys.stdout.flush()
|
|
746
|
+
|
|
747
|
+
with sd.InputStream(samplerate=_SAMPLE_RATE, channels=_CHANNELS, dtype="int16", blocksize=_FRAME_SIZE, device=device_idx, callback=_audio_callback):
|
|
748
|
+
sender_task = asyncio.create_task(_sender(ws))
|
|
749
|
+
receiver_task = asyncio.create_task(_chat_receiver(ws, output_device_idx, verbose, loop, stop_event, playing))
|
|
750
|
+
try:
|
|
751
|
+
await asyncio.gather(sender_task, receiver_task)
|
|
752
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
753
|
+
stop_event.set()
|
|
754
|
+
sender_task.cancel()
|
|
755
|
+
receiver_task.cancel()
|
|
756
|
+
raise KeyboardInterrupt
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
@app.command()
|
|
760
|
+
def live(
|
|
761
|
+
model: str = typer.Option(
|
|
762
|
+
"Systran/faster-whisper-tiny",
|
|
763
|
+
"--model",
|
|
764
|
+
"-m",
|
|
765
|
+
help="STT model to use",
|
|
766
|
+
),
|
|
767
|
+
language: str | None = typer.Option(None, "--language", "-l", help="Language code (e.g. 'en'). Auto-detect if omitted."),
|
|
768
|
+
task: str = typer.Option("transcribe", "--task", help="'transcribe' or 'translate'"),
|
|
769
|
+
device: str | None = typer.Option(None, "--device", "-d", help="Input device index or name substring. Run `vocal devices` to list."),
|
|
770
|
+
api_url: str = typer.Option("http://localhost:8000", "--api-url", help="Vocal API URL"),
|
|
771
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show timing info on each utterance"),
|
|
772
|
+
) -> None:
|
|
773
|
+
"""Stream microphone audio over WebSocket and print transcriptions as they arrive (~200ms latency)"""
|
|
774
|
+
try:
|
|
775
|
+
device_idx = _resolve_device(device)
|
|
776
|
+
except ValueError as e:
|
|
777
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
778
|
+
raise typer.Exit(1)
|
|
779
|
+
|
|
780
|
+
ws_url = api_url.replace("http://", "ws://").replace("https://", "wss://")
|
|
781
|
+
params = f"model={model}&task={task}"
|
|
782
|
+
if language:
|
|
783
|
+
params += f"&language={language}"
|
|
784
|
+
endpoint = f"{ws_url}/v1/audio/stream?{params}"
|
|
785
|
+
|
|
786
|
+
active_device = sd.query_devices(device_idx if device_idx is not None else sd.default.device[0])
|
|
787
|
+
device_label = f"[dim]{active_device['name']}[/dim]"
|
|
788
|
+
console.print(f"[green]Live streaming...[/green] model=[cyan]{model}[/cyan] task=[cyan]{task}[/cyan] device={device_label} Ctrl+C to stop\n")
|
|
789
|
+
|
|
790
|
+
try:
|
|
791
|
+
asyncio.run(_live_async(endpoint, device_idx, verbose))
|
|
792
|
+
except KeyboardInterrupt:
|
|
793
|
+
console.print("\n[yellow]Stopped.[/yellow]")
|
|
794
|
+
except Exception as e:
|
|
795
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
796
|
+
raise typer.Exit(1)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
async def _ws_sender(ws, audio_q: queue.SimpleQueue, stop_event: asyncio.Event, loop: asyncio.AbstractEventLoop) -> None:
|
|
800
|
+
while not stop_event.is_set():
|
|
801
|
+
try:
|
|
802
|
+
frame = await loop.run_in_executor(None, audio_q.get, True, 0.1)
|
|
803
|
+
await ws.send(frame)
|
|
804
|
+
except queue.Empty:
|
|
805
|
+
continue
|
|
806
|
+
except Exception:
|
|
807
|
+
break
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
async def _ws_receiver(ws, verbose: bool) -> None:
|
|
811
|
+
current_line = ""
|
|
812
|
+
t_start = time.monotonic()
|
|
813
|
+
async for msg in ws:
|
|
814
|
+
event = json.loads(msg)
|
|
815
|
+
etype = event.get("type", "")
|
|
816
|
+
if etype == "transcript.delta":
|
|
817
|
+
current_line = current_line + event.get("text", "") + " "
|
|
818
|
+
sys.stdout.write(f"\r> {current_line} ")
|
|
819
|
+
sys.stdout.flush()
|
|
820
|
+
elif etype == "transcript.done":
|
|
821
|
+
full = event.get("text", "").strip()
|
|
822
|
+
elapsed = time.monotonic() - t_start
|
|
823
|
+
suffix = f" ({elapsed:.1f}s)" if verbose else ""
|
|
824
|
+
sys.stdout.write("\r" + " " * 80 + "\r")
|
|
825
|
+
sys.stdout.flush()
|
|
826
|
+
if full:
|
|
827
|
+
console.print(f"[cyan]>[/cyan] {full}{suffix}")
|
|
828
|
+
current_line = ""
|
|
829
|
+
t_start = time.monotonic()
|
|
830
|
+
elif etype == "error":
|
|
831
|
+
console.print(f"\n[red]error:[/red] {event.get('message', 'unknown')}")
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
async def _live_async(endpoint: str, device_idx: int | None, verbose: bool) -> None:
|
|
835
|
+
audio_q: queue.SimpleQueue = queue.SimpleQueue()
|
|
836
|
+
loop = asyncio.get_running_loop()
|
|
837
|
+
stop_event = asyncio.Event()
|
|
838
|
+
|
|
839
|
+
def _audio_callback(indata, _frames, _ts, _status) -> None:
|
|
840
|
+
audio_q.put_nowait(indata.copy().tobytes())
|
|
841
|
+
|
|
842
|
+
async with websockets.connect(endpoint) as ws:
|
|
843
|
+
with sd.InputStream(samplerate=_SAMPLE_RATE, channels=_CHANNELS, dtype="int16", blocksize=_FRAME_SIZE, device=device_idx, callback=_audio_callback):
|
|
844
|
+
sender_task = asyncio.create_task(_ws_sender(ws, audio_q, stop_event, loop))
|
|
845
|
+
receiver_task = asyncio.create_task(_ws_receiver(ws, verbose))
|
|
846
|
+
try:
|
|
847
|
+
await asyncio.gather(sender_task, receiver_task)
|
|
848
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
849
|
+
stop_event.set()
|
|
850
|
+
sender_task.cancel()
|
|
851
|
+
receiver_task.cancel()
|
|
852
|
+
raise KeyboardInterrupt
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _format_timestamp(seconds: float, use_comma: bool = True) -> str:
|
|
856
|
+
hours = int(seconds // 3600)
|
|
857
|
+
minutes = int((seconds % 3600) // 60)
|
|
858
|
+
secs = seconds % 60
|
|
859
|
+
if use_comma:
|
|
860
|
+
return f"{hours:02d}:{minutes:02d}:{secs:06.3f}".replace(".", ",")
|
|
861
|
+
return f"{hours:02d}:{minutes:02d}:{secs:06.3f}"
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
if __name__ == "__main__":
|
|
865
|
+
app()
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
import httpx
|
|
5
|
-
import typer
|
|
6
|
-
from rich.console import Console
|
|
7
|
-
from rich.table import Table
|
|
8
|
-
|
|
9
|
-
from vocal_sdk import VocalClient
|
|
10
|
-
from vocal_sdk.api.models import (
|
|
11
|
-
delete_model_v1_models_model_id_delete,
|
|
12
|
-
download_model_v1_models_model_id_download_post,
|
|
13
|
-
list_models_v1_models_get,
|
|
14
|
-
)
|
|
15
|
-
from vocal_sdk.api.transcription import create_transcription_v1_audio_transcriptions_post
|
|
16
|
-
from vocal_sdk.models import BodyCreateTranscriptionV1AudioTranscriptionsPost, TranscriptionFormat
|
|
17
|
-
from vocal_sdk.types import UNSET, File, Unset
|
|
18
|
-
|
|
19
|
-
app = typer.Typer(
|
|
20
|
-
name="vocal",
|
|
21
|
-
help="Vocal - Generic Speech AI Platform CLI",
|
|
22
|
-
no_args_is_help=True,
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
models_app = typer.Typer(help="Model management commands")
|
|
26
|
-
app.add_typer(models_app, name="models")
|
|
27
|
-
|
|
28
|
-
console = Console()
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _make_client(api_url: str) -> VocalClient:
|
|
32
|
-
return VocalClient(base_url=api_url, timeout=httpx.Timeout(300.0), raise_on_unexpected_status=True)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@app.command()
|
|
36
|
-
def run(
|
|
37
|
-
audio_file: Path = typer.Argument(..., help="Path to audio file to transcribe"),
|
|
38
|
-
model: str = typer.Option(
|
|
39
|
-
"Systran/faster-whisper-tiny",
|
|
40
|
-
"--model",
|
|
41
|
-
"-m",
|
|
42
|
-
help="Model to use for transcription",
|
|
43
|
-
),
|
|
44
|
-
language: str | None = typer.Option(None, "--language", "-l", help="Language code (e.g., 'en', 'es')"),
|
|
45
|
-
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text, json, srt, vtt"),
|
|
46
|
-
api_url: str = typer.Option("http://localhost:8000", "--api-url", help="Vocal API URL"),
|
|
47
|
-
):
|
|
48
|
-
"""Transcribe audio file to text"""
|
|
49
|
-
if not audio_file.exists():
|
|
50
|
-
console.print(f"[red]Error:[/red] File not found: {audio_file}")
|
|
51
|
-
raise typer.Exit(1)
|
|
52
|
-
|
|
53
|
-
try:
|
|
54
|
-
vc = _make_client(api_url)
|
|
55
|
-
console.print("Transcribing audio...")
|
|
56
|
-
|
|
57
|
-
with open(audio_file, "rb") as fobj:
|
|
58
|
-
body = BodyCreateTranscriptionV1AudioTranscriptionsPost(
|
|
59
|
-
file=File(payload=fobj, file_name=audio_file.name),
|
|
60
|
-
model=model,
|
|
61
|
-
language=language if language is not None else UNSET,
|
|
62
|
-
response_format=TranscriptionFormat.JSON,
|
|
63
|
-
)
|
|
64
|
-
result = create_transcription_v1_audio_transcriptions_post.sync(client=vc, body=body)
|
|
65
|
-
|
|
66
|
-
if result is None:
|
|
67
|
-
console.print("[red]Error:[/red] Transcription failed - no response")
|
|
68
|
-
raise typer.Exit(1)
|
|
69
|
-
|
|
70
|
-
if output_format == "text":
|
|
71
|
-
console.print(result.text)
|
|
72
|
-
elif output_format == "json":
|
|
73
|
-
console.print_json(json.dumps(result.to_dict()))
|
|
74
|
-
elif output_format in ("srt", "vtt"):
|
|
75
|
-
segs = [] if isinstance(result.segments, Unset) or result.segments is None else result.segments
|
|
76
|
-
if output_format == "vtt":
|
|
77
|
-
console.print("WEBVTT\n")
|
|
78
|
-
for seg in segs:
|
|
79
|
-
if output_format == "srt":
|
|
80
|
-
console.print(f"{seg.id + 1}")
|
|
81
|
-
console.print(f"{_format_timestamp(seg.start)} --> {_format_timestamp(seg.end)}")
|
|
82
|
-
else:
|
|
83
|
-
console.print(f"{_format_timestamp(seg.start, use_comma=False)} --> {_format_timestamp(seg.end, use_comma=False)}")
|
|
84
|
-
console.print(seg.text)
|
|
85
|
-
console.print()
|
|
86
|
-
|
|
87
|
-
except Exception as e:
|
|
88
|
-
console.print(f"[red]Error:[/red] {str(e)}")
|
|
89
|
-
raise typer.Exit(1)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
@models_app.command("list")
|
|
93
|
-
def models_list(
|
|
94
|
-
task: str | None = typer.Option(None, "--task", "-t", help="Filter by task: stt, tts"),
|
|
95
|
-
api_url: str = typer.Option("http://localhost:8000", "--api-url", help="Vocal API URL"),
|
|
96
|
-
):
|
|
97
|
-
"""List all available models"""
|
|
98
|
-
try:
|
|
99
|
-
vc = _make_client(api_url)
|
|
100
|
-
response = list_models_v1_models_get.sync(client=vc, task=task if task is not None else UNSET)
|
|
101
|
-
|
|
102
|
-
if response is None:
|
|
103
|
-
console.print("[red]Error:[/red] Failed to list models")
|
|
104
|
-
raise typer.Exit(1)
|
|
105
|
-
|
|
106
|
-
table = Table(title="Vocal Models")
|
|
107
|
-
table.add_column("Model ID", style="cyan")
|
|
108
|
-
table.add_column("Task", style="magenta")
|
|
109
|
-
table.add_column("Status", style="green")
|
|
110
|
-
table.add_column("Size", style="yellow")
|
|
111
|
-
|
|
112
|
-
for m in response.models:
|
|
113
|
-
status_val = m.status.value
|
|
114
|
-
status_color = {
|
|
115
|
-
"available": "green",
|
|
116
|
-
"downloading": "yellow",
|
|
117
|
-
"not_downloaded": "red",
|
|
118
|
-
}.get(status_val, "white")
|
|
119
|
-
size = str(m.size_readable) if not isinstance(m.size_readable, Unset) else "N/A"
|
|
120
|
-
table.add_row(
|
|
121
|
-
m.id,
|
|
122
|
-
m.task.value,
|
|
123
|
-
f"[{status_color}]{status_val}[/{status_color}]",
|
|
124
|
-
size,
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
console.print(table)
|
|
128
|
-
console.print(f"\nTotal models: {response.total}")
|
|
129
|
-
|
|
130
|
-
except Exception as e:
|
|
131
|
-
console.print(f"[red]Error:[/red] {str(e)}")
|
|
132
|
-
raise typer.Exit(1)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
@models_app.command("pull")
|
|
136
|
-
def models_pull(
|
|
137
|
-
model_id: str = typer.Argument(..., help="Model ID to download"),
|
|
138
|
-
api_url: str = typer.Option("http://localhost:8000", "--api-url", help="Vocal API URL"),
|
|
139
|
-
):
|
|
140
|
-
"""Download a model (Ollama-style pull)"""
|
|
141
|
-
try:
|
|
142
|
-
vc = _make_client(api_url)
|
|
143
|
-
console.print(f"Downloading {model_id}...")
|
|
144
|
-
result = download_model_v1_models_model_id_download_post.sync(model_id=model_id, client=vc)
|
|
145
|
-
console.print(f"[green]Successfully downloaded:[/green] {model_id}")
|
|
146
|
-
if result is not None:
|
|
147
|
-
console.print(f"Status: {result.status.value}")
|
|
148
|
-
except Exception as e:
|
|
149
|
-
console.print(f"[red]Error:[/red] {str(e)}")
|
|
150
|
-
raise typer.Exit(1)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
@models_app.command("delete")
|
|
154
|
-
def models_delete(
|
|
155
|
-
model_id: str = typer.Argument(..., help="Model ID to delete"),
|
|
156
|
-
api_url: str = typer.Option("http://localhost:8000", "--api-url", help="Vocal API URL"),
|
|
157
|
-
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
|
|
158
|
-
):
|
|
159
|
-
"""Delete a downloaded model"""
|
|
160
|
-
if not force:
|
|
161
|
-
confirm = typer.confirm(f"Are you sure you want to delete {model_id}?")
|
|
162
|
-
if not confirm:
|
|
163
|
-
console.print("Cancelled")
|
|
164
|
-
raise typer.Exit(0)
|
|
165
|
-
|
|
166
|
-
try:
|
|
167
|
-
vc = _make_client(api_url)
|
|
168
|
-
delete_model_v1_models_model_id_delete.sync(model_id=model_id, client=vc)
|
|
169
|
-
console.print(f"[green]Successfully deleted:[/green] {model_id}")
|
|
170
|
-
except Exception as e:
|
|
171
|
-
console.print(f"[red]Error:[/red] {str(e)}")
|
|
172
|
-
raise typer.Exit(1)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
@app.command()
|
|
176
|
-
def serve(
|
|
177
|
-
host: str = typer.Option("0.0.0.0", "--host", "-h", help="Host to bind to"),
|
|
178
|
-
port: int = typer.Option(8000, "--port", "-p", help="Port to bind to"),
|
|
179
|
-
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
|
|
180
|
-
):
|
|
181
|
-
"""Start the Vocal API server"""
|
|
182
|
-
import uvicorn
|
|
183
|
-
|
|
184
|
-
console.print("[green]Starting Vocal API server...[/green]")
|
|
185
|
-
console.print(f"API: http://{host}:{port}")
|
|
186
|
-
console.print(f"Docs: http://{host}:{port}/docs")
|
|
187
|
-
|
|
188
|
-
try:
|
|
189
|
-
uvicorn.run("vocal_api.main:app", host=host, port=port, reload=reload)
|
|
190
|
-
except KeyboardInterrupt:
|
|
191
|
-
console.print("\n[yellow]Server stopped[/yellow]")
|
|
192
|
-
except Exception as e:
|
|
193
|
-
console.print(f"[red]Error:[/red] {str(e)}")
|
|
194
|
-
raise typer.Exit(1)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def _format_timestamp(seconds: float, use_comma: bool = True) -> str:
|
|
198
|
-
hours = int(seconds // 3600)
|
|
199
|
-
minutes = int((seconds % 3600) // 60)
|
|
200
|
-
secs = seconds % 60
|
|
201
|
-
if use_comma:
|
|
202
|
-
return f"{hours:02d}:{minutes:02d}:{secs:06.3f}".replace(".", ",")
|
|
203
|
-
return f"{hours:02d}:{minutes:02d}:{secs:06.3f}"
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if __name__ == "__main__":
|
|
207
|
-
app()
|
|
File without changes
|
|
File without changes
|