voice-mode 2.25.1__py3-none-any.whl → 2.27.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. voice_mode/__version__.py +1 -1
  2. voice_mode/cli.py +773 -3
  3. voice_mode/config.py +11 -3
  4. voice_mode/frontend/.next/BUILD_ID +1 -1
  5. voice_mode/frontend/.next/app-build-manifest.json +5 -5
  6. voice_mode/frontend/.next/app-path-routes-manifest.json +1 -1
  7. voice_mode/frontend/.next/build-manifest.json +3 -3
  8. voice_mode/frontend/.next/next-minimal-server.js.nft.json +1 -1
  9. voice_mode/frontend/.next/next-server.js.nft.json +1 -1
  10. voice_mode/frontend/.next/prerender-manifest.json +1 -1
  11. voice_mode/frontend/.next/required-server-files.json +1 -1
  12. voice_mode/frontend/.next/server/app/_not-found/page.js +1 -1
  13. voice_mode/frontend/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. voice_mode/frontend/.next/server/app/_not-found.html +1 -1
  15. voice_mode/frontend/.next/server/app/_not-found.rsc +1 -1
  16. voice_mode/frontend/.next/server/app/api/connection-details/route.js +2 -2
  17. voice_mode/frontend/.next/server/app/favicon.ico/route.js +2 -2
  18. voice_mode/frontend/.next/server/app/index.html +1 -1
  19. voice_mode/frontend/.next/server/app/index.rsc +2 -2
  20. voice_mode/frontend/.next/server/app/page.js +2 -2
  21. voice_mode/frontend/.next/server/app/page_client-reference-manifest.js +1 -1
  22. voice_mode/frontend/.next/server/app-paths-manifest.json +1 -1
  23. voice_mode/frontend/.next/server/chunks/994.js +1 -1
  24. voice_mode/frontend/.next/server/middleware-build-manifest.js +1 -1
  25. voice_mode/frontend/.next/server/next-font-manifest.js +1 -1
  26. voice_mode/frontend/.next/server/next-font-manifest.json +1 -1
  27. voice_mode/frontend/.next/server/pages/404.html +1 -1
  28. voice_mode/frontend/.next/server/pages/500.html +1 -1
  29. voice_mode/frontend/.next/server/server-reference-manifest.json +1 -1
  30. voice_mode/frontend/.next/standalone/.next/BUILD_ID +1 -1
  31. voice_mode/frontend/.next/standalone/.next/app-build-manifest.json +5 -5
  32. voice_mode/frontend/.next/standalone/.next/app-path-routes-manifest.json +1 -1
  33. voice_mode/frontend/.next/standalone/.next/build-manifest.json +3 -3
  34. voice_mode/frontend/.next/standalone/.next/prerender-manifest.json +1 -1
  35. voice_mode/frontend/.next/standalone/.next/required-server-files.json +1 -1
  36. voice_mode/frontend/.next/standalone/.next/server/app/_not-found/page.js +1 -1
  37. voice_mode/frontend/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  38. voice_mode/frontend/.next/standalone/.next/server/app/_not-found.html +1 -1
  39. voice_mode/frontend/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  40. voice_mode/frontend/.next/standalone/.next/server/app/api/connection-details/route.js +2 -2
  41. voice_mode/frontend/.next/standalone/.next/server/app/favicon.ico/route.js +2 -2
  42. voice_mode/frontend/.next/standalone/.next/server/app/index.html +1 -1
  43. voice_mode/frontend/.next/standalone/.next/server/app/index.rsc +2 -2
  44. voice_mode/frontend/.next/standalone/.next/server/app/page.js +2 -2
  45. voice_mode/frontend/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  46. voice_mode/frontend/.next/standalone/.next/server/app-paths-manifest.json +1 -1
  47. voice_mode/frontend/.next/standalone/.next/server/chunks/994.js +1 -1
  48. voice_mode/frontend/.next/standalone/.next/server/middleware-build-manifest.js +1 -1
  49. voice_mode/frontend/.next/standalone/.next/server/next-font-manifest.js +1 -1
  50. voice_mode/frontend/.next/standalone/.next/server/next-font-manifest.json +1 -1
  51. voice_mode/frontend/.next/standalone/.next/server/pages/404.html +1 -1
  52. voice_mode/frontend/.next/standalone/.next/server/pages/500.html +1 -1
  53. voice_mode/frontend/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  54. voice_mode/frontend/.next/standalone/server.js +1 -1
  55. voice_mode/frontend/.next/static/chunks/app/{layout-72afb99823b83b84.js → layout-08be62ed6e344292.js} +1 -1
  56. voice_mode/frontend/.next/static/chunks/app/page-80fc72669f25298f.js +1 -0
  57. voice_mode/frontend/.next/static/chunks/{main-app-309f92817de3aa2e.js → main-app-413f77c1f2c53e3f.js} +1 -1
  58. voice_mode/frontend/.next/trace +43 -43
  59. voice_mode/frontend/.next/types/app/api/connection-details/route.ts +1 -1
  60. voice_mode/frontend/.next/types/app/layout.ts +1 -1
  61. voice_mode/frontend/.next/types/app/page.ts +1 -1
  62. voice_mode/frontend/package-lock.json +8 -8
  63. voice_mode/resources/configuration.py +8 -4
  64. voice_mode/resources/whisper_models.py +10 -13
  65. voice_mode/templates/systemd/voicemode-frontend.service +1 -1
  66. voice_mode/tools/configuration_management.py +7 -2
  67. voice_mode/tools/converse.py +31 -0
  68. voice_mode/tools/services/kokoro/install.py +3 -2
  69. voice_mode/tools/services/whisper/__init__.py +13 -0
  70. voice_mode/tools/services/whisper/install.py +3 -2
  71. voice_mode/tools/services/whisper/list_models.py +70 -0
  72. voice_mode/tools/services/whisper/list_models_tool.py +65 -0
  73. voice_mode/tools/services/whisper/models.py +274 -0
  74. {voice_mode-2.25.1.dist-info → voice_mode-2.27.0.dist-info}/METADATA +1 -1
  75. {voice_mode-2.25.1.dist-info → voice_mode-2.27.0.dist-info}/RECORD +79 -75
  76. voice_mode/frontend/.next/static/chunks/app/page-da4c53726baa2f11.js +0 -1
  77. /voice_mode/frontend/.next/static/{hqX0-c5ao91YRHgub6Vk- → wQ5pxzPmwjlzdUfJwSjMg}/_buildManifest.js +0 -0
  78. /voice_mode/frontend/.next/static/{hqX0-c5ao91YRHgub6Vk- → wQ5pxzPmwjlzdUfJwSjMg}/_ssgManifest.js +0 -0
  79. {voice_mode-2.25.1.dist-info → voice_mode-2.27.0.dist-info}/WHEEL +0 -0
  80. {voice_mode-2.25.1.dist-info → voice_mode-2.27.0.dist-info}/entry_points.txt +0 -0
voice_mode/cli.py CHANGED
@@ -7,6 +7,7 @@ import os
7
7
  import warnings
8
8
  import click
9
9
 
10
+
10
11
  # Suppress known deprecation warnings for better user experience
11
12
  # These apply to both CLI commands and MCP server operation
12
13
  # They can be shown with VOICEMODE_DEBUG=true or --debug flag
@@ -420,12 +421,183 @@ def uninstall(remove_models, remove_all_data):
420
421
  click.echo(f" Details: {result['details']}")
421
422
 
422
423
 
423
- @whisper.command("download-model")
424
+ @whisper.group("model")
425
+ def whisper_model():
426
+ """Manage Whisper models.
427
+
428
+ Subcommands:
429
+ active - Show or set the active model
430
+ install - Download and install models
431
+ remove - Remove installed models
432
+ """
433
+ pass
434
+
435
+
436
+ @whisper_model.command("active")
437
+ @click.argument('model_name', required=False)
438
+ def whisper_model_active(model_name):
439
+ """Show or set the active Whisper model.
440
+
441
+ Without arguments: Shows the current active model
442
+ With MODEL_NAME: Sets the active model (updates VOICEMODE_WHISPER_MODEL)
443
+ """
444
+ from voice_mode.tools.services.whisper.models import (
445
+ get_current_model,
446
+ WHISPER_MODELS,
447
+ is_model_installed,
448
+ set_current_model
449
+ )
450
+ import os
451
+ import subprocess
452
+
453
+ if model_name:
454
+ # Set model mode
455
+ if model_name not in WHISPER_MODELS:
456
+ click.echo(f"Error: '{model_name}' is not a valid model.", err=True)
457
+ click.echo("\nAvailable models:", err=True)
458
+ for name in WHISPER_MODELS.keys():
459
+ click.echo(f" - {name}", err=True)
460
+ return
461
+
462
+ # Check if model is installed
463
+ if not is_model_installed(model_name):
464
+ click.echo(f"Error: Model '{model_name}' is not installed.", err=True)
465
+ click.echo(f"Install it with: voice-mode whisper model install {model_name}", err=True)
466
+ raise click.Abort()
467
+
468
+ # Get previous model
469
+ previous_model = get_current_model()
470
+
471
+ # Update the configuration file
472
+ set_current_model(model_name)
473
+
474
+ click.echo(f"✓ Active model set to: {model_name}")
475
+ if previous_model != model_name:
476
+ click.echo(f" (was: {previous_model})")
477
+
478
+ # Check if whisper service is running
479
+ try:
480
+ result = subprocess.run(['pgrep', '-f', 'whisper-server'], capture_output=True)
481
+ if result.returncode == 0:
482
+ # Service is running
483
+ click.echo(f"\n⚠️ Please restart the whisper service for changes to take effect:")
484
+ click.echo(f" {click.style('voice-mode whisper restart', fg='yellow', bold=True)}")
485
+ else:
486
+ click.echo(f"\nWhisper service is not running. Start it with:")
487
+ click.echo(f" voice-mode whisper start")
488
+ click.echo(f"(or restart the whisper service if it's managed by systemd/launchd)")
489
+ except:
490
+ click.echo(f"\nPlease restart the whisper service for changes to take effect:")
491
+ click.echo(f" voice-mode whisper restart")
492
+
493
+ else:
494
+ # Show current model
495
+ current = get_current_model()
496
+
497
+ # Check if current model is installed
498
+ installed = is_model_installed(current)
499
+ status = click.style("[✓ Installed]", fg="green") if installed else click.style("[Not installed]", fg="red")
500
+
501
+ # Get model info
502
+ model_info = WHISPER_MODELS.get(current, {})
503
+
504
+ click.echo(f"\nActive Whisper model: {click.style(current, fg='yellow', bold=True)} {status}")
505
+ if model_info:
506
+ click.echo(f" Size: {model_info.get('size_mb', 'Unknown')} MB")
507
+ click.echo(f" Languages: {model_info.get('languages', 'Unknown')}")
508
+ click.echo(f" Description: {model_info.get('description', 'Unknown')}")
509
+
510
+ # Check what model the service is actually using
511
+ try:
512
+ result = subprocess.run(['pgrep', '-f', 'whisper-server'], capture_output=True)
513
+ if result.returncode == 0:
514
+ # Service is running, could check its actual model here
515
+ click.echo(f"\nWhisper service status: {click.style('Running', fg='green')}")
516
+ except:
517
+ pass
518
+
519
+ click.echo(f"\nTo change: voice-mode whisper model active <model-name>")
520
+ click.echo(f"To list all models: voice-mode whisper models")
521
+
522
+
523
+ @whisper.command("models")
524
+ def whisper_models():
525
+ """List available Whisper models and their installation status."""
526
+ from voice_mode.tools.services.whisper.models import (
527
+ WHISPER_MODELS,
528
+ get_model_directory,
529
+ get_current_model,
530
+ is_model_installed,
531
+ get_installed_models,
532
+ format_size
533
+ )
534
+
535
+ model_dir = get_model_directory()
536
+ current_model = get_current_model()
537
+ installed_models = get_installed_models()
538
+
539
+ # Calculate totals
540
+ total_installed_size = sum(
541
+ WHISPER_MODELS[m]["size_mb"] for m in installed_models
542
+ )
543
+ total_available_size = sum(
544
+ m["size_mb"] for m in WHISPER_MODELS.values()
545
+ )
546
+
547
+ # Print header
548
+ click.echo("\nWhisper Models:")
549
+ click.echo("")
550
+
551
+ # Print models table
552
+ for model_name, info in WHISPER_MODELS.items():
553
+ # Check status
554
+ is_installed = is_model_installed(model_name)
555
+ is_current = model_name == current_model
556
+
557
+ # Format status
558
+ if is_current:
559
+ status = click.style("→", fg="yellow", bold=True)
560
+ model_display = click.style(f"{model_name:15}", fg="yellow", bold=True)
561
+ else:
562
+ status = " "
563
+ model_display = f"{model_name:15}"
564
+
565
+ # Format installation status
566
+ if is_installed:
567
+ install_status = click.style("[✓ Installed]", fg="green")
568
+ else:
569
+ install_status = click.style("[ Download ]", fg="bright_black")
570
+
571
+ # Format size
572
+ size_str = format_size(info["size_mb"]).rjust(8)
573
+
574
+ # Format languages
575
+ lang_str = f"{info['languages']:20}"
576
+
577
+ # Format description
578
+ desc = info['description']
579
+ if is_current:
580
+ desc += " (Currently selected)"
581
+ desc = click.style(desc, fg="yellow")
582
+
583
+ # Print row
584
+ click.echo(f"{status} {model_display} {install_status:14} {size_str} {lang_str} {desc}")
585
+
586
+ # Print footer
587
+ click.echo("")
588
+ click.echo(f"Models directory: {model_dir}")
589
+ click.echo(f"Total size: {format_size(total_installed_size)} installed / {format_size(total_available_size)} available")
590
+ click.echo("")
591
+ click.echo("To download a model: voice-mode whisper model install <model-name>")
592
+ click.echo("To set default model: voice-mode whisper model <model-name>")
593
+
594
+
595
+ @whisper_model.command("install")
424
596
  @click.argument('model', default='large-v2')
425
597
  @click.option('--force', '-f', is_flag=True, help='Re-download even if model exists')
426
598
  @click.option('--skip-core-ml', is_flag=True, help='Skip Core ML conversion on Apple Silicon')
427
- def download_model_cmd(model, force, skip_core_ml):
428
- """Download Whisper model(s) with optional Core ML conversion.
599
+ def whisper_model_install(model, force, skip_core_ml):
600
+ """Install Whisper model(s) with optional Core ML conversion.
429
601
 
430
602
  MODEL can be a model name (e.g., 'large-v2'), 'all' to download all models,
431
603
  or omitted to use the default (large-v2).
@@ -472,6 +644,74 @@ def download_model_cmd(model, force, skip_core_ml):
472
644
  click.echo(result)
473
645
 
474
646
 
647
+ @whisper_model.command("remove")
648
+ @click.argument('model')
649
+ @click.option('--force', '-f', is_flag=True, help='Remove without confirmation')
650
+ def whisper_model_remove(model, force):
651
+ """Remove an installed Whisper model.
652
+
653
+ MODEL is the name of the model to remove (e.g., 'large-v2').
654
+ """
655
+ from voice_mode.tools.services.whisper.models import (
656
+ WHISPER_MODELS,
657
+ is_model_installed,
658
+ get_model_directory,
659
+ get_current_model
660
+ )
661
+ import os
662
+
663
+ # Validate model name
664
+ if model not in WHISPER_MODELS:
665
+ click.echo(f"Error: '{model}' is not a valid model.", err=True)
666
+ click.echo("\nAvailable models:", err=True)
667
+ for name in WHISPER_MODELS.keys():
668
+ click.echo(f" - {name}", err=True)
669
+ ctx.exit(1)
670
+
671
+ # Check if model is installed
672
+ if not is_model_installed(model):
673
+ click.echo(f"Model '{model}' is not installed.")
674
+ return
675
+
676
+ # Check if it's the current model
677
+ current = get_current_model()
678
+ if model == current:
679
+ click.echo(f"Warning: '{model}' is the currently selected model.", err=True)
680
+ if not force:
681
+ if not click.confirm("Do you still want to remove it?"):
682
+ return
683
+
684
+ # Get model path
685
+ model_dir = get_model_directory()
686
+ model_info = WHISPER_MODELS[model]
687
+ model_path = model_dir / model_info["filename"]
688
+
689
+ # Also check for Core ML models
690
+ coreml_path = model_dir / f"ggml-{model}-encoder.mlmodelc"
691
+
692
+ # Confirm removal if not forced
693
+ if not force:
694
+ size_mb = model_info["size_mb"]
695
+ if not click.confirm(f"Remove {model} ({size_mb} MB)?"):
696
+ return
697
+
698
+ # Remove the model file
699
+ try:
700
+ if model_path.exists():
701
+ os.remove(model_path)
702
+ click.echo(f"✓ Removed model: {model}")
703
+
704
+ # Remove Core ML model if exists
705
+ if coreml_path.exists():
706
+ import shutil
707
+ shutil.rmtree(coreml_path)
708
+ click.echo(f"✓ Removed Core ML model: {model}")
709
+
710
+ click.echo(f"\nModel '{model}' has been removed.")
711
+ except Exception as e:
712
+ click.echo(f"Error removing model: {e}", err=True)
713
+
714
+
475
715
  # LiveKit service commands
476
716
  @livekit.command()
477
717
  def status():
@@ -897,6 +1137,150 @@ def config_set(key, value):
897
1137
  click.echo(result)
898
1138
 
899
1139
 
1140
+ # Shell completion group
1141
+ @voice_mode_main_cli.group()
1142
+ def completion():
1143
+ """Generate shell completion scripts for voice-mode."""
1144
+ pass
1145
+
1146
+
1147
+ @completion.command("bash")
1148
+ def completion_bash():
1149
+ """Generate bash completion script.
1150
+
1151
+ Add this to your ~/.bashrc:
1152
+
1153
+ eval "$(_VOICE_MODE_COMPLETE=bash_source voice-mode)"
1154
+
1155
+ Or for better performance, generate the script once:
1156
+
1157
+ _VOICE_MODE_COMPLETE=bash_source voice-mode > ~/.voice-mode-complete.bash
1158
+ echo '. ~/.voice-mode-complete.bash' >> ~/.bashrc
1159
+ """
1160
+ # Output the instructions directly since the environment variable method
1161
+ # needs to be run from the shell itself
1162
+ click.echo("# Bash completion for voice-mode")
1163
+ click.echo("# Add this to your ~/.bashrc:")
1164
+ click.echo("")
1165
+ click.echo('eval "$(_VOICE_MODE_COMPLETE=bash_source voice-mode)"')
1166
+ click.echo("")
1167
+ click.echo("# Or for better performance, generate and save the script:")
1168
+ click.echo("# _VOICE_MODE_COMPLETE=bash_source voice-mode > ~/.voice-mode-complete.bash")
1169
+ click.echo("# Then add to ~/.bashrc:")
1170
+ click.echo("# . ~/.voice-mode-complete.bash")
1171
+
1172
+
1173
+ @completion.command("zsh")
1174
+ def completion_zsh():
1175
+ """Generate zsh completion script.
1176
+
1177
+ Add this to your ~/.zshrc:
1178
+
1179
+ eval "$(_VOICE_MODE_COMPLETE=zsh_source voice-mode)"
1180
+
1181
+ Or for better performance, generate the script once:
1182
+
1183
+ _VOICE_MODE_COMPLETE=zsh_source voice-mode > ~/.voice-mode-complete.zsh
1184
+ echo '. ~/.voice-mode-complete.zsh' >> ~/.zshrc
1185
+ """
1186
+ # Output the instructions directly
1187
+ click.echo("# Zsh completion for voice-mode")
1188
+ click.echo("# Add this to your ~/.zshrc:")
1189
+ click.echo("")
1190
+ click.echo('eval "$(_VOICE_MODE_COMPLETE=zsh_source voice-mode)"')
1191
+ click.echo("")
1192
+ click.echo("# Or for better performance, generate and save the script:")
1193
+ click.echo("# _VOICE_MODE_COMPLETE=zsh_source voice-mode > ~/.voice-mode-complete.zsh")
1194
+ click.echo("# Then add to ~/.zshrc:")
1195
+ click.echo("# . ~/.voice-mode-complete.zsh")
1196
+
1197
+
1198
+ @completion.command("fish")
1199
+ def completion_fish():
1200
+ """Generate fish completion script.
1201
+
1202
+ Add this to ~/.config/fish/completions/voice-mode.fish:
1203
+
1204
+ _VOICE_MODE_COMPLETE=fish_source voice-mode | source
1205
+
1206
+ Or save it to a file:
1207
+
1208
+ _VOICE_MODE_COMPLETE=fish_source voice-mode > ~/.config/fish/completions/voice-mode.fish
1209
+ """
1210
+ # Output the instructions directly
1211
+ click.echo("# Fish completion for voice-mode")
1212
+ click.echo("# Save this to ~/.config/fish/completions/voice-mode.fish:")
1213
+ click.echo("")
1214
+ click.echo("_VOICE_MODE_COMPLETE=fish_source voice-mode | source")
1215
+ click.echo("")
1216
+ click.echo("# Or run this command to save it:")
1217
+ click.echo("# _VOICE_MODE_COMPLETE=fish_source voice-mode > ~/.config/fish/completions/voice-mode.fish")
1218
+
1219
+
1220
+ @completion.command("install")
1221
+ @click.option('--shell', type=click.Choice(['bash', 'zsh', 'fish', 'auto']), default='auto', help='Shell type to install for')
1222
+ def completion_install(shell):
1223
+ """Show installation instructions for shell completion.
1224
+
1225
+ This command displays the steps to enable tab completion
1226
+ for voice-mode in your shell.
1227
+ """
1228
+ # Detect shell if auto
1229
+ if shell == 'auto':
1230
+ shell_env = os.environ.get('SHELL', '')
1231
+ if 'bash' in shell_env:
1232
+ shell = 'bash'
1233
+ elif 'zsh' in shell_env:
1234
+ shell = 'zsh'
1235
+ elif 'fish' in shell_env:
1236
+ shell = 'fish'
1237
+ else:
1238
+ click.echo("Could not detect shell type. Showing instructions for all shells.")
1239
+ click.echo("")
1240
+
1241
+ # Show all instructions
1242
+ click.echo("=== Bash ===")
1243
+ click.echo("Add to ~/.bashrc:")
1244
+ click.echo(' eval "$(_VOICE_MODE_COMPLETE=bash_source voice-mode)"')
1245
+ click.echo("")
1246
+
1247
+ click.echo("=== Zsh ===")
1248
+ click.echo("Add to ~/.zshrc:")
1249
+ click.echo(' eval "$(_VOICE_MODE_COMPLETE=zsh_source voice-mode)"')
1250
+ click.echo("")
1251
+
1252
+ click.echo("=== Fish ===")
1253
+ click.echo("Run this command:")
1254
+ click.echo(' _VOICE_MODE_COMPLETE=fish_source voice-mode > ~/.config/fish/completions/voice-mode.fish')
1255
+ return
1256
+
1257
+ click.echo(f"To enable tab completion for voice-mode in {shell}:")
1258
+ click.echo("")
1259
+
1260
+ if shell == 'bash':
1261
+ click.echo("Add this line to your ~/.bashrc:")
1262
+ click.echo(' eval "$(_VOICE_MODE_COMPLETE=bash_source voice-mode)"')
1263
+ click.echo("")
1264
+ click.echo("Or for better performance, generate the script once:")
1265
+ click.echo(" _VOICE_MODE_COMPLETE=bash_source voice-mode > ~/.voice-mode-complete.bash")
1266
+ click.echo(" echo '. ~/.voice-mode-complete.bash' >> ~/.bashrc")
1267
+ elif shell == 'zsh':
1268
+ click.echo("Add this line to your ~/.zshrc:")
1269
+ click.echo(' eval "$(_VOICE_MODE_COMPLETE=zsh_source voice-mode)"')
1270
+ click.echo("")
1271
+ click.echo("Or for better performance, generate the script once:")
1272
+ click.echo(" _VOICE_MODE_COMPLETE=zsh_source voice-mode > ~/.voice-mode-complete.zsh")
1273
+ click.echo(" echo '. ~/.voice-mode-complete.zsh' >> ~/.zshrc")
1274
+ elif shell == 'fish':
1275
+ click.echo("Run this command:")
1276
+ click.echo(' _VOICE_MODE_COMPLETE=fish_source voice-mode > ~/.config/fish/completions/voice-mode.fish')
1277
+ click.echo("")
1278
+ click.echo("Fish will automatically load the completion from this location.")
1279
+
1280
+ click.echo("")
1281
+ click.echo("After making these changes, restart your shell or source the config file.")
1282
+
1283
+
900
1284
  # Diagnostics group
901
1285
  @voice_mode_main_cli.group()
902
1286
  def diag():
@@ -994,3 +1378,389 @@ cli.add_command(exchanges_cmd.exchanges)
994
1378
  voice_mode_main_cli.add_command(exchanges_cmd.exchanges)
995
1379
 
996
1380
 
1381
+ # Converse command - direct voice conversation from CLI
1382
+ @voice_mode_main_cli.command()
1383
+ @click.option('--message', '-m', default="Hello! How can I help you today?", help='Initial message to speak')
1384
+ @click.option('--wait/--no-wait', default=True, help='Wait for response after speaking')
1385
+ @click.option('--duration', '-d', type=float, default=30.0, help='Listen duration in seconds')
1386
+ @click.option('--min-duration', type=float, default=2.0, help='Minimum listen duration before silence detection')
1387
+ @click.option('--transport', type=click.Choice(['auto', 'local', 'livekit']), default='auto', help='Transport method')
1388
+ @click.option('--room-name', default='', help='LiveKit room name (for livekit transport)')
1389
+ @click.option('--voice', help='TTS voice to use (e.g., nova, shimmer, af_sky)')
1390
+ @click.option('--tts-provider', type=click.Choice(['openai', 'kokoro']), help='TTS provider')
1391
+ @click.option('--tts-model', help='TTS model (e.g., tts-1, tts-1-hd)')
1392
+ @click.option('--tts-instructions', help='Tone/style instructions for gpt-4o-mini-tts')
1393
+ @click.option('--audio-feedback/--no-audio-feedback', default=None, help='Enable/disable audio feedback')
1394
+ @click.option('--audio-format', help='Audio format (pcm, mp3, wav, flac, aac, opus)')
1395
+ @click.option('--disable-silence-detection', is_flag=True, help='Disable silence detection')
1396
+ @click.option('--speed', type=float, help='Speech rate (0.25 to 4.0)')
1397
+ @click.option('--vad-aggressiveness', type=int, help='VAD aggressiveness (0-3)')
1398
+ @click.option('--skip-tts/--no-skip-tts', default=None, help='Skip TTS and only show text')
1399
+ @click.option('--continuous', '-c', is_flag=True, help='Continuous conversation mode')
1400
+ def converse(message, wait, duration, min_duration, transport, room_name, voice, tts_provider,
1401
+ tts_model, tts_instructions, audio_feedback, audio_format, disable_silence_detection,
1402
+ speed, vad_aggressiveness, skip_tts, continuous):
1403
+ """Have a voice conversation directly from the command line.
1404
+
1405
+ Examples:
1406
+
1407
+ # Simple conversation
1408
+ voice-mode converse
1409
+
1410
+ # Speak a message without waiting
1411
+ voice-mode converse -m "Hello there!" --no-wait
1412
+
1413
+ # Continuous conversation mode
1414
+ voice-mode converse --continuous
1415
+
1416
+ # Use specific voice
1417
+ voice-mode converse --voice nova
1418
+ """
1419
+ from voice_mode.tools.converse import converse as converse_fn
1420
+
1421
+ async def run_conversation():
1422
+ """Run the conversation asynchronously."""
1423
+ try:
1424
+ if continuous:
1425
+ # Continuous conversation mode
1426
+ click.echo("🎤 Starting continuous conversation mode...")
1427
+ click.echo(" Press Ctrl+C to exit\n")
1428
+
1429
+ # First message
1430
+ result = await converse_fn.fn(
1431
+ message=message,
1432
+ wait_for_response=True,
1433
+ listen_duration=duration,
1434
+ min_listen_duration=min_duration,
1435
+ transport=transport,
1436
+ room_name=room_name,
1437
+ voice=voice,
1438
+ tts_provider=tts_provider,
1439
+ tts_model=tts_model,
1440
+ tts_instructions=tts_instructions,
1441
+ audio_feedback=audio_feedback,
1442
+ audio_feedback_style=None,
1443
+ audio_format=audio_format,
1444
+ disable_silence_detection=disable_silence_detection,
1445
+ speed=speed,
1446
+ vad_aggressiveness=vad_aggressiveness,
1447
+ skip_tts=skip_tts
1448
+ )
1449
+
1450
+ if result and "Voice response:" in result:
1451
+ click.echo(f"You: {result.split('Voice response:')[1].split('|')[0].strip()}")
1452
+
1453
+ # Continue conversation
1454
+ while True:
1455
+ # Wait for user's next input
1456
+ result = await converse_fn.fn(
1457
+ message="", # Empty message for listening only
1458
+ wait_for_response=True,
1459
+ listen_duration=duration,
1460
+ min_listen_duration=min_duration,
1461
+ transport=transport,
1462
+ room_name=room_name,
1463
+ voice=voice,
1464
+ tts_provider=tts_provider,
1465
+ tts_model=tts_model,
1466
+ tts_instructions=tts_instructions,
1467
+ audio_feedback=audio_feedback,
1468
+ audio_feedback_style=None,
1469
+ audio_format=audio_format,
1470
+ disable_silence_detection=disable_silence_detection,
1471
+ speed=speed,
1472
+ vad_aggressiveness=vad_aggressiveness,
1473
+ skip_tts=skip_tts
1474
+ )
1475
+
1476
+ if result and "Voice response:" in result:
1477
+ user_text = result.split('Voice response:')[1].split('|')[0].strip()
1478
+ click.echo(f"You: {user_text}")
1479
+
1480
+ # Check for exit commands
1481
+ if user_text.lower() in ['exit', 'quit', 'goodbye', 'bye']:
1482
+ await converse_fn.fn(
1483
+ message="Goodbye!",
1484
+ wait_for_response=False,
1485
+ voice=voice,
1486
+ tts_provider=tts_provider,
1487
+ tts_model=tts_model,
1488
+ audio_format=audio_format,
1489
+ speed=speed,
1490
+ skip_tts=skip_tts
1491
+ )
1492
+ break
1493
+ else:
1494
+ # Single conversation
1495
+ result = await converse_fn.fn(
1496
+ message=message,
1497
+ wait_for_response=wait,
1498
+ listen_duration=duration,
1499
+ min_listen_duration=min_duration,
1500
+ transport=transport,
1501
+ room_name=room_name,
1502
+ voice=voice,
1503
+ tts_provider=tts_provider,
1504
+ tts_model=tts_model,
1505
+ tts_instructions=tts_instructions,
1506
+ audio_feedback=audio_feedback,
1507
+ audio_feedback_style=None,
1508
+ audio_format=audio_format,
1509
+ disable_silence_detection=disable_silence_detection,
1510
+ speed=speed,
1511
+ vad_aggressiveness=vad_aggressiveness,
1512
+ skip_tts=skip_tts
1513
+ )
1514
+
1515
+ # Display result
1516
+ if result:
1517
+ if "Voice response:" in result:
1518
+ # Extract the response text and timing info
1519
+ parts = result.split('|')
1520
+ response_text = result.split('Voice response:')[1].split('|')[0].strip()
1521
+ timing_info = parts[1].strip() if len(parts) > 1 else ""
1522
+
1523
+ click.echo(f"\n📢 Spoke: {message}")
1524
+ if wait:
1525
+ click.echo(f"🎤 Heard: {response_text}")
1526
+ if timing_info:
1527
+ click.echo(f"⏱️ {timing_info}")
1528
+ else:
1529
+ click.echo(result)
1530
+
1531
+ except KeyboardInterrupt:
1532
+ click.echo("\n\n👋 Conversation ended")
1533
+ except Exception as e:
1534
+ click.echo(f"❌ Error: {e}", err=True)
1535
+ import traceback
1536
+ if os.environ.get('VOICEMODE_DEBUG'):
1537
+ traceback.print_exc()
1538
+
1539
+ # Run the async function
1540
+ asyncio.run(run_conversation())
1541
+
1542
+
1543
+ # Version command
1544
+ @voice_mode_main_cli.command()
1545
+ def version():
1546
+ """Show Voice Mode version and check for updates."""
1547
+ import requests
1548
+ from importlib.metadata import version as get_version, PackageNotFoundError
1549
+
1550
+ try:
1551
+ current_version = get_version("voice-mode")
1552
+ except PackageNotFoundError:
1553
+ # Fallback for development installations
1554
+ current_version = "development"
1555
+
1556
+ click.echo(f"Voice Mode version: {current_version}")
1557
+
1558
+ # Check for updates if not in development mode
1559
+ if current_version != "development":
1560
+ try:
1561
+ response = requests.get(
1562
+ "https://pypi.org/pypi/voice-mode/json",
1563
+ timeout=2
1564
+ )
1565
+ if response.status_code == 200:
1566
+ latest_version = response.json()["info"]["version"]
1567
+
1568
+ # Simple version comparison (works for semantic versioning)
1569
+ if latest_version != current_version:
1570
+ click.echo(f"Latest version: {latest_version} available")
1571
+ click.echo("Run 'voicemode update' to update")
1572
+ else:
1573
+ click.echo("You are running the latest version")
1574
+ except (requests.RequestException, KeyError, ValueError):
1575
+ # Fail silently if we can't check for updates
1576
+ pass
1577
+
1578
+
1579
+ # Update command
1580
+ @voice_mode_main_cli.command()
1581
+ @click.option('--force', is_flag=True, help='Force reinstall even if already up to date')
1582
+ def update(force):
1583
+ """Update Voice Mode to the latest version."""
1584
+ import subprocess
1585
+ import requests
1586
+ from importlib.metadata import version as get_version, PackageNotFoundError
1587
+
1588
+ try:
1589
+ current_version = get_version("voice-mode")
1590
+ except PackageNotFoundError:
1591
+ current_version = "development"
1592
+
1593
+ if not force and current_version != "development":
1594
+ # Check if update is needed
1595
+ try:
1596
+ response = requests.get(
1597
+ "https://pypi.org/pypi/voice-mode/json",
1598
+ timeout=2
1599
+ )
1600
+ if response.status_code == 200:
1601
+ latest_version = response.json()["info"]["version"]
1602
+ if latest_version == current_version:
1603
+ click.echo(f"Already running the latest version ({current_version})")
1604
+ return
1605
+ except (requests.RequestException, KeyError, ValueError):
1606
+ # Continue with update if we can't check
1607
+ pass
1608
+
1609
+ click.echo("Updating Voice Mode to the latest version...")
1610
+
1611
+ # Try UV first, fall back to pip
1612
+ try:
1613
+ # Check if UV is available
1614
+ result = subprocess.run(
1615
+ ["uv", "--version"],
1616
+ capture_output=True,
1617
+ text=True,
1618
+ check=False
1619
+ )
1620
+
1621
+ if result.returncode == 0:
1622
+ # Use UV for update
1623
+ result = subprocess.run(
1624
+ ["uv", "pip", "install", "--upgrade", "voice-mode"],
1625
+ capture_output=True,
1626
+ text=True
1627
+ )
1628
+ if result.returncode == 0:
1629
+ # Get new version
1630
+ try:
1631
+ new_version = get_version("voice-mode")
1632
+ click.echo(f"✅ Successfully updated to version {new_version}")
1633
+ except PackageNotFoundError:
1634
+ click.echo("✅ Successfully updated Voice Mode")
1635
+ else:
1636
+ click.echo(f"❌ Update failed: {result.stderr}")
1637
+ click.echo("Try running: uv pip install --upgrade voice-mode")
1638
+ else:
1639
+ # Fall back to pip
1640
+ result = subprocess.run(
1641
+ [sys.executable, "-m", "pip", "install", "--upgrade", "voice-mode"],
1642
+ capture_output=True,
1643
+ text=True
1644
+ )
1645
+ if result.returncode == 0:
1646
+ try:
1647
+ new_version = get_version("voice-mode")
1648
+ click.echo(f"✅ Successfully updated to version {new_version}")
1649
+ except PackageNotFoundError:
1650
+ click.echo("✅ Successfully updated Voice Mode")
1651
+ else:
1652
+ click.echo(f"❌ Update failed: {result.stderr}")
1653
+ click.echo("Try running: pip install --upgrade voice-mode")
1654
+
1655
+ except FileNotFoundError as e:
1656
+ click.echo(f"❌ Update failed: {e}")
1657
+ click.echo("Please install UV or pip and try again")
1658
+
1659
+
1660
+ # Completions command
1661
+ @voice_mode_main_cli.command()
1662
+ @click.argument('shell', type=click.Choice(['bash', 'zsh', 'fish']))
1663
+ @click.option('--install', is_flag=True, help='Install completion script to the appropriate location')
1664
+ def completions(shell, install):
1665
+ """Generate or install shell completion scripts.
1666
+
1667
+ Examples:
1668
+ voicemode completions bash # Output bash completion to stdout
1669
+ voicemode completions bash --install # Install to ~/.bash_completion.d/
1670
+ voicemode completions zsh --install # Install to ~/.zfunc/
1671
+ voicemode completions fish --install # Install to ~/.config/fish/completions/
1672
+ """
1673
+ from pathlib import Path
1674
+
1675
+ # Generate completion scripts based on shell type
1676
+ if shell == 'bash':
1677
+ completion_script = '''# bash completion for voicemode
1678
+ _voicemode_completion() {
1679
+ local IFS=$'\\n'
1680
+ local response
1681
+
1682
+ response=$(env _VOICEMODE_COMPLETE=bash_complete COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD voicemode 2>/dev/null)
1683
+
1684
+ for completion in $response; do
1685
+ IFS=',' read type value <<< "$completion"
1686
+
1687
+ if [[ $type == 'plain' ]]; then
1688
+ COMPREPLY+=("$value")
1689
+ elif [[ $type == 'file' ]]; then
1690
+ COMPREPLY+=("$value")
1691
+ elif [[ $type == 'dir' ]]; then
1692
+ COMPREPLY+=("$value")
1693
+ fi
1694
+ done
1695
+
1696
+ return 0
1697
+ }
1698
+
1699
+ complete -o default -F _voicemode_completion voicemode
1700
+ '''
1701
+
1702
+ elif shell == 'zsh':
1703
+ completion_script = '''#compdef voicemode
1704
+ # zsh completion for voicemode
1705
+
1706
+ _voicemode() {
1707
+ local -a response
1708
+ response=(${(f)"$(env _VOICEMODE_COMPLETE=zsh_complete COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) voicemode 2>/dev/null)"})
1709
+
1710
+ for completion in $response; do
1711
+ IFS=',' read type value <<< "$completion"
1712
+ compadd -U -- "$value"
1713
+ done
1714
+ }
1715
+
1716
+ compdef _voicemode voicemode
1717
+ '''
1718
+
1719
+ elif shell == 'fish':
1720
+ completion_script = '''# fish completion for voicemode
1721
+ function __fish_voicemode_complete
1722
+ set -l response (env _VOICEMODE_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) voicemode 2>/dev/null)
1723
+
1724
+ for completion in $response
1725
+ echo $completion
1726
+ end
1727
+ end
1728
+
1729
+ complete -c voicemode -f -a '(__fish_voicemode_complete)'
1730
+ '''
1731
+
1732
+ if install:
1733
+ # Define installation locations for each shell
1734
+ locations = {
1735
+ 'bash': '~/.bash_completion.d/voicemode',
1736
+ 'zsh': '~/.zfunc/_voicemode',
1737
+ 'fish': '~/.config/fish/completions/voicemode.fish'
1738
+ }
1739
+
1740
+ install_path = Path(locations[shell]).expanduser()
1741
+ install_path.parent.mkdir(parents=True, exist_ok=True)
1742
+
1743
+ # Write completion script to file
1744
+ install_path.write_text(completion_script)
1745
+ click.echo(f"✅ Installed {shell} completions to {install_path}")
1746
+
1747
+ # Provide shell-specific instructions
1748
+ if shell == 'bash':
1749
+ click.echo("\nTo activate now, run:")
1750
+ click.echo(f" source {install_path}")
1751
+ click.echo("\nTo activate permanently, add to ~/.bashrc:")
1752
+ click.echo(f" source {install_path}")
1753
+ elif shell == 'zsh':
1754
+ click.echo("\nTo activate now, run:")
1755
+ click.echo(" autoload -U compinit && compinit")
1756
+ click.echo("\nMake sure ~/.zfunc is in your fpath (add to ~/.zshrc):")
1757
+ click.echo(" fpath=(~/.zfunc $fpath)")
1758
+ elif shell == 'fish':
1759
+ click.echo("\nCompletions will be active in new fish sessions.")
1760
+ click.echo("To activate now, run:")
1761
+ click.echo(f" source {install_path}")
1762
+ else:
1763
+ # Output completion script to stdout
1764
+ click.echo(completion_script)
1765
+
1766
+