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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vocal-cli
3
- Version: 0.3.4
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-sdk>=0.3.4
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.4"
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-sdk>=0.3.4",
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]
@@ -4,4 +4,4 @@ Vocal CLI - Command-line interface for Vocal Speech AI Platform
4
4
  This CLI provides Ollama-style commands for model management and audio transcription.
5
5
  """
6
6
 
7
- __version__ = "0.3.4"
7
+ __version__ = "0.3.5"
@@ -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