voice-mode 4.4.0__tar.gz → 4.5.0__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.
Files changed (136) hide show
  1. {voice_mode-4.4.0 → voice_mode-4.5.0}/CHANGELOG.md +33 -0
  2. {voice_mode-4.4.0 → voice_mode-4.5.0}/PKG-INFO +5 -2
  3. {voice_mode-4.4.0 → voice_mode-4.5.0}/README.md +4 -1
  4. {voice_mode-4.4.0 → voice_mode-4.5.0}/pyproject.toml +8 -0
  5. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/__version__.py +1 -1
  6. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/cli.py +79 -3
  7. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/cli_commands/transcribe.py +7 -6
  8. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/config.py +1 -1
  9. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/conversation_logger.py +6 -0
  10. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/core.py +9 -2
  11. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/provider_discovery.py +55 -79
  12. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/providers.py +61 -45
  13. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/simple_failover.py +41 -12
  14. voice_mode-4.5.0/voice_mode/tools/__init__.py +158 -0
  15. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/converse.py +148 -337
  16. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/diagnostics.py +2 -1
  17. voice_mode-4.5.0/voice_mode/tools/voice_registry.py +62 -0
  18. voice_mode-4.4.0/voice_mode/tools/__init__.py +0 -50
  19. voice_mode-4.4.0/voice_mode/tools/voice_registry.py +0 -66
  20. {voice_mode-4.4.0 → voice_mode-4.5.0}/.gitignore +0 -0
  21. {voice_mode-4.4.0 → voice_mode-4.5.0}/build_hooks.py +0 -0
  22. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/__init__.py +0 -0
  23. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/__main__.py +0 -0
  24. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/cli_commands/__init__.py +0 -0
  25. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/cli_commands/claude.py +0 -0
  26. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/cli_commands/exchanges.py +0 -0
  27. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/cli_commands/hook.py +0 -0
  28. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/cli_commands/pronounce_commands.py +0 -0
  29. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/data/default_pronunciation.yaml +0 -0
  30. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/data/versions.json +0 -0
  31. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/exchanges/__init__.py +0 -0
  32. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/exchanges/conversations.py +0 -0
  33. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/exchanges/filters.py +0 -0
  34. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/exchanges/formatters.py +0 -0
  35. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/exchanges/models.py +0 -0
  36. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/exchanges/reader.py +0 -0
  37. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/exchanges/stats.py +0 -0
  38. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/README.md +0 -0
  39. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/app/api/connection-details/route.ts +0 -0
  40. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/app/favicon.ico +0 -0
  41. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/app/globals.css +0 -0
  42. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/app/layout.tsx +0 -0
  43. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/app/page.tsx +0 -0
  44. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/components/CloseIcon.tsx +0 -0
  45. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/components/NoAgentNotification.tsx +0 -0
  46. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/components/TranscriptionView.tsx +0 -0
  47. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/hooks/useCombinedTranscriptions.ts +0 -0
  48. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/hooks/useLocalMicTrack.ts +0 -0
  49. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/next-env.d.ts +0 -0
  50. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/next.config.mjs +0 -0
  51. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/package-lock.json +0 -0
  52. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/package.json +0 -0
  53. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/pnpm-lock.yaml +0 -0
  54. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/postcss.config.mjs +0 -0
  55. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/tailwind.config.ts +0 -0
  56. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/frontend/tsconfig.json +0 -0
  57. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/prompts/README.md +0 -0
  58. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/prompts/__init__.py +0 -0
  59. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/prompts/converse.py +0 -0
  60. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/prompts/release_notes.py +0 -0
  61. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/prompts/services.py +0 -0
  62. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/pronounce.py +0 -0
  63. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/resources/__init__.py +0 -0
  64. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/resources/audio_files.py +0 -0
  65. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/resources/changelog.py +0 -0
  66. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/resources/configuration.py +0 -0
  67. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/resources/statistics.py +0 -0
  68. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/resources/version.py +0 -0
  69. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/resources/whisper_models.py +0 -0
  70. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/server.py +0 -0
  71. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/shared.py +0 -0
  72. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/statistics.py +0 -0
  73. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/statistics_tracking.py +0 -0
  74. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/streaming.py +0 -0
  75. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/__init__.py +0 -0
  76. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/launchd/com.voicemode.frontend.plist +0 -0
  77. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/launchd/com.voicemode.kokoro.plist +0 -0
  78. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/launchd/com.voicemode.livekit.plist +0 -0
  79. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/launchd/com.voicemode.whisper.plist +0 -0
  80. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/launchd/start-kokoro-with-health-check.sh +0 -0
  81. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/launchd/start-whisper-with-health-check.sh +0 -0
  82. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/scripts/__init__.py +0 -0
  83. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/scripts/start-whisper-server.sh +0 -0
  84. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/systemd/voicemode-frontend.service +0 -0
  85. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/systemd/voicemode-kokoro.service +0 -0
  86. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/systemd/voicemode-livekit.service +0 -0
  87. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/templates/systemd/voicemode-whisper.service +0 -0
  88. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/configuration_management.py +0 -0
  89. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/dependencies.py +0 -0
  90. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/devices.py +0 -0
  91. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/pronounce.py +0 -0
  92. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/providers.py +0 -0
  93. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/service.py +0 -0
  94. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/kokoro/install.py +0 -0
  95. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/kokoro/uninstall.py +0 -0
  96. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/list_versions.py +0 -0
  97. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/livekit/__init__.py +0 -0
  98. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/livekit/frontend.py +0 -0
  99. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/livekit/install.py +0 -0
  100. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/livekit/production_server.py +0 -0
  101. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/livekit/uninstall.py +0 -0
  102. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/version_info.py +0 -0
  103. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/whisper/__init__.py +0 -0
  104. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/whisper/install.py +0 -0
  105. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/whisper/list_models.py +0 -0
  106. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/whisper/model_active.py +0 -0
  107. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/whisper/model_benchmark.py +0 -0
  108. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/whisper/model_install.py +0 -0
  109. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/whisper/model_remove.py +0 -0
  110. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/whisper/models.py +0 -0
  111. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/services/whisper/uninstall.py +0 -0
  112. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/sound_fonts/__init__.py +0 -0
  113. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/sound_fonts/audio_player.py +0 -0
  114. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/sound_fonts/hook_handler.py +0 -0
  115. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/sound_fonts/player.py +0 -0
  116. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/statistics.py +0 -0
  117. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/transcription/__init__.py +0 -0
  118. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/transcription/backends.py +0 -0
  119. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/transcription/core.py +0 -0
  120. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/transcription/formats.py +0 -0
  121. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/tools/transcription/types.py +0 -0
  122. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/__init__.py +0 -0
  123. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/audio_diagnostics.py +0 -0
  124. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/event_logger.py +0 -0
  125. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/ffmpeg_check.py +0 -0
  126. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/format_migration.py +0 -0
  127. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/gpu_detection.py +0 -0
  128. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/migration_helpers.py +0 -0
  129. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/services/common.py +0 -0
  130. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/services/coreml_setup.py +0 -0
  131. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/services/kokoro_helpers.py +0 -0
  132. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/services/livekit_helpers.py +0 -0
  133. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/services/whisper_helpers.py +0 -0
  134. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/services/whisper_version.py +0 -0
  135. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/utils/version_helpers.py +0 -0
  136. {voice_mode-4.4.0 → voice_mode-4.5.0}/voice_mode/version.py +0 -0
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.5.0] - 2025-09-18
11
+
12
+ ### Added
13
+ - **Enhanced STT Logging**
14
+ - Add comprehensive logging for speech-to-text operations
15
+ - Log provider selection and fallback attempts
16
+ - Include transcription details and provider info in logs
17
+
18
+ - **Configuration Management**
19
+ - Add `voicemode config edit` command for easy configuration file editing
20
+ - Support custom editor selection via --editor flag
21
+ - Automatically open configuration file in default editor
22
+
23
+ - **Tool Environment Variables**
24
+ - Replace VOICEMODE_TOOLS with VOICEMODE_TOOLS_ENABLED and VOICEMODE_TOOLS_DISABLED
25
+ - Allow fine-grained control over tool availability
26
+ - Support comma-separated lists for enabling/disabling specific tools
27
+
28
+ ### Changed
29
+ - **Provider Selection Architecture**
30
+ - Consolidate dual provider selection systems into single simple failover approach
31
+ - Remove SIMPLE_FAILOVER configuration - simple failover is now the only mode
32
+ - Simplify get_tts_config and get_stt_config to use direct configuration
33
+ - Eliminate ~400 lines of unused provider registry selection logic
34
+ - Provider registry now only stores endpoint info without complex selection
35
+
36
+ ### Fixed
37
+ - Disable OpenAI client retries for local endpoints to avoid delays
38
+ - Fix logger name consistency (voicemode vs voice-mode) for STT logging
39
+ - Prevent test_installers from killing running voice services during tests
40
+ - Update tests to work with refactored provider system
41
+ - Resolve test failures related to new environment variables
42
+
10
43
  ## [4.4.0] - 2025-09-10
11
44
 
12
45
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voice-mode
3
- Version: 4.4.0
3
+ Version: 4.5.0
4
4
  Summary: VoiceMode - Voice interaction capabilities for AI assistants (formerly voice-mcp)
5
5
  Project-URL: Homepage, https://github.com/mbailey/voicemode
6
6
  Project-URL: Repository, https://github.com/mbailey/voicemode
@@ -261,9 +261,12 @@ claude mcp add --scope user voice-mode uvx voice-mode
261
261
  # Using Claude Code with Nix (NixOS)
262
262
  claude mcp add voice-mode nix run github:mbailey/voicemode
263
263
 
264
- # Using UV
264
+ # Using UV (recommended)
265
265
  uvx voice-mode
266
266
 
267
+ # For cleanest experience with UV (no deprecation warnings):
268
+ UV_PYTHON=python3.13 uvx voice-mode
269
+
267
270
  # Using pip
268
271
  pip install voice-mode
269
272
 
@@ -183,9 +183,12 @@ claude mcp add --scope user voice-mode uvx voice-mode
183
183
  # Using Claude Code with Nix (NixOS)
184
184
  claude mcp add voice-mode nix run github:mbailey/voicemode
185
185
 
186
- # Using UV
186
+ # Using UV (recommended)
187
187
  uvx voice-mode
188
188
 
189
+ # For cleanest experience with UV (no deprecation warnings):
190
+ UV_PYTHON=python3.13 uvx voice-mode
191
+
189
192
  # Using pip
190
193
  pip install voice-mode
191
194
 
@@ -210,3 +210,11 @@ directory = "htmlcov"
210
210
 
211
211
  [tool.coverage.xml]
212
212
  output = "coverage.xml"
213
+
214
+ [dependency-groups]
215
+ dev = [
216
+ "pytest>=8.4.2",
217
+ "pytest-asyncio>=1.2.0",
218
+ "pytest-cov>=7.0.0",
219
+ "pytest-mock>=3.15.0",
220
+ ]
@@ -1,3 +1,3 @@
1
1
  # This file is automatically updated by 'make release'
2
2
  # Do not edit manually
3
- __version__ = "4.4.0"
3
+ __version__ = "4.5.0"
@@ -5,6 +5,8 @@ import asyncio
5
5
  import sys
6
6
  import os
7
7
  import warnings
8
+ import subprocess
9
+ import shutil
8
10
  import click
9
11
 
10
12
 
@@ -30,10 +32,12 @@ if not os.environ.get('VOICEMODE_DEBUG', '').lower() in ('true', '1', 'yes'):
30
32
  @click.version_option()
31
33
  @click.help_option('-h', '--help', help='Show this message and exit')
32
34
  @click.option('--debug', is_flag=True, help='Enable debug mode and show all warnings')
35
+ @click.option('--tools-enabled', help='Comma-separated list of tools to enable (whitelist)')
36
+ @click.option('--tools-disabled', help='Comma-separated list of tools to disable (blacklist)')
33
37
  @click.pass_context
34
- def voice_mode_main_cli(ctx, debug):
38
+ def voice_mode_main_cli(ctx, debug, tools_enabled, tools_disabled):
35
39
  """Voice Mode - MCP server and service management.
36
-
40
+
37
41
  Without arguments, starts the MCP server.
38
42
  With subcommands, executes service management operations.
39
43
  """
@@ -44,7 +48,13 @@ def voice_mode_main_cli(ctx, debug):
44
48
  # Re-enable INFO logging
45
49
  import logging
46
50
  logging.getLogger("voice-mode").setLevel(logging.INFO)
47
-
51
+
52
+ # Set environment variables from CLI args
53
+ if tools_enabled:
54
+ os.environ['VOICEMODE_TOOLS_ENABLED'] = tools_enabled
55
+ if tools_disabled:
56
+ os.environ['VOICEMODE_TOOLS_DISABLED'] = tools_disabled
57
+
48
58
  if ctx.invoked_subcommand is None:
49
59
  # No subcommand - run MCP server
50
60
  # Note: warnings are already suppressed at module level unless debug is enabled
@@ -1277,6 +1287,72 @@ def config_set(key, value):
1277
1287
  click.echo(result)
1278
1288
 
1279
1289
 
1290
+ @config.command("edit")
1291
+ @click.help_option('-h', '--help')
1292
+ @click.option('--editor', help='Editor to use (overrides $EDITOR)')
1293
+ def config_edit(editor):
1294
+ """Open the configuration file in your default editor.
1295
+
1296
+ Opens ~/.voicemode/voicemode.env in your configured editor.
1297
+ Uses $EDITOR environment variable by default, or you can specify with --editor.
1298
+
1299
+ Examples:
1300
+ voicemode config edit # Use $EDITOR
1301
+ voicemode config edit --editor vim
1302
+ voicemode config edit --editor "code --wait"
1303
+ """
1304
+ from pathlib import Path
1305
+
1306
+ # Find the config file
1307
+ config_path = Path.home() / ".voicemode" / "voicemode.env"
1308
+
1309
+ # Create default config if it doesn't exist
1310
+ if not config_path.exists():
1311
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1312
+ from voice_mode.config import load_voicemode_env
1313
+ # This will create the default config
1314
+ load_voicemode_env()
1315
+
1316
+ # Determine which editor to use
1317
+ if editor:
1318
+ editor_cmd = editor
1319
+ else:
1320
+ # Try environment variables in order of preference
1321
+ editor_cmd = (
1322
+ os.environ.get('EDITOR') or
1323
+ os.environ.get('VISUAL') or
1324
+ shutil.which('nano') or
1325
+ shutil.which('vim') or
1326
+ shutil.which('vi')
1327
+ )
1328
+
1329
+ if not editor_cmd:
1330
+ click.echo("❌ No editor found. Please set $EDITOR or use --editor")
1331
+ click.echo(" Example: export EDITOR=vim")
1332
+ click.echo(" Or use: voicemode config edit --editor vim")
1333
+ return
1334
+
1335
+ # Handle complex editor commands (e.g., "code --wait")
1336
+ if ' ' in editor_cmd:
1337
+ import shlex
1338
+ cmd_parts = shlex.split(editor_cmd)
1339
+ cmd = cmd_parts + [str(config_path)]
1340
+ else:
1341
+ cmd = [editor_cmd, str(config_path)]
1342
+
1343
+ # Open the editor
1344
+ try:
1345
+ click.echo(f"Opening {config_path} in {editor_cmd}...")
1346
+ subprocess.run(cmd, check=True)
1347
+ click.echo("✅ Configuration file edited successfully")
1348
+ click.echo("\nChanges will take effect when voicemode is restarted.")
1349
+ except subprocess.CalledProcessError:
1350
+ click.echo(f"❌ Editor exited with an error")
1351
+ except FileNotFoundError:
1352
+ click.echo(f"❌ Editor not found: {editor_cmd}")
1353
+ click.echo(" Please check that the editor is installed and in your PATH")
1354
+
1355
+
1280
1356
  # Diagnostics group
1281
1357
  @voice_mode_main_cli.group()
1282
1358
  @click.help_option('-h', '--help', help='Show this message and exit')
@@ -6,12 +6,6 @@ import asyncio
6
6
  from pathlib import Path
7
7
  from typing import Optional
8
8
 
9
- from voice_mode.tools.transcription import (
10
- transcribe_audio,
11
- TranscriptionBackend,
12
- OutputFormat
13
- )
14
-
15
9
 
16
10
  @click.group()
17
11
  def transcribe():
@@ -61,6 +55,13 @@ def audio_command(
61
55
  voice-mode transcribe audio spanish.mp3 --language es --backend whisperx
62
56
  """
63
57
  async def run():
58
+ # Import here to avoid loading tools at module level
59
+ from voice_mode.tools.transcription import (
60
+ transcribe_audio,
61
+ TranscriptionBackend,
62
+ OutputFormat
63
+ )
64
+
64
65
  # Perform transcription
65
66
  result = await transcribe_audio(
66
67
  audio_file=audio_file,
@@ -253,7 +253,7 @@ PREFER_LOCAL = os.getenv("VOICEMODE_PREFER_LOCAL", "true").lower() in ("true", "
253
253
  ALWAYS_TRY_LOCAL = os.getenv("VOICEMODE_ALWAYS_TRY_LOCAL", "true").lower() in ("true", "1", "yes", "on")
254
254
 
255
255
  # Use simple failover without health checks
256
- SIMPLE_FAILOVER = os.getenv("VOICEMODE_SIMPLE_FAILOVER", "true").lower() in ("true", "1", "yes", "on")
256
+ # Simple failover is now the only mode - configuration removed
257
257
 
258
258
  # Auto-start configuration
259
259
  AUTO_START_KOKORO = os.getenv("VOICEMODE_AUTO_START_KOKORO", "").lower() in ("true", "1", "yes", "on")
@@ -189,6 +189,9 @@ class ConversationLogger:
189
189
  "timing": kwargs.get("timing"),
190
190
  "silence_detection": kwargs.get("silence_detection"),
191
191
  "error": kwargs.get("error"),
192
+ # Fallback information
193
+ "is_fallback": kwargs.get("is_fallback"),
194
+ "fallback_reason": kwargs.get("fallback_reason"),
192
195
  # Timing metrics
193
196
  "transcription_time": kwargs.get("transcription_time"),
194
197
  "total_turnaround_time": kwargs.get("total_turnaround_time"),
@@ -205,6 +208,9 @@ class ConversationLogger:
205
208
  "provider": kwargs.get("provider"),
206
209
  "provider_url": kwargs.get("provider_url"),
207
210
  "provider_type": kwargs.get("provider_type"),
211
+ # Fallback information
212
+ "is_fallback": kwargs.get("is_fallback"),
213
+ "fallback_reason": kwargs.get("fallback_reason"),
208
214
  "audio_format": kwargs.get("audio_format"),
209
215
  "timing": kwargs.get("timing"),
210
216
  "transport": kwargs.get("transport"),
@@ -18,6 +18,7 @@ from typing import Optional
18
18
  import numpy as np
19
19
  from pydub import AudioSegment
20
20
  from openai import AsyncOpenAI
21
+ from .provider_discovery import is_local_provider
21
22
  import httpx
22
23
 
23
24
  from .config import SAMPLE_RATE
@@ -135,16 +136,22 @@ def get_openai_clients(api_key: str, stt_base_url: Optional[str] = None, tts_bas
135
136
  'limits': httpx.Limits(max_keepalive_connections=5, max_connections=10),
136
137
  }
137
138
 
139
+ # Disable retries for local endpoints - they either work or don't
140
+ stt_max_retries = 0 if is_local_provider(stt_base_url) else 2
141
+ tts_max_retries = 0 if is_local_provider(tts_base_url) else 2
142
+
138
143
  return {
139
144
  'stt': AsyncOpenAI(
140
145
  api_key=api_key,
141
146
  base_url=stt_base_url,
142
- http_client=httpx.AsyncClient(**http_client_config)
147
+ http_client=httpx.AsyncClient(**http_client_config),
148
+ max_retries=stt_max_retries
143
149
  ),
144
150
  'tts': AsyncOpenAI(
145
151
  api_key=api_key,
146
152
  base_url=tts_base_url,
147
- http_client=httpx.AsyncClient(**http_client_config)
153
+ http_client=httpx.AsyncClient(**http_client_config),
154
+ max_retries=tts_max_retries
148
155
  )
149
156
  }
150
157
 
@@ -26,6 +26,8 @@ logger = logging.getLogger("voice-mode")
26
26
 
27
27
  def detect_provider_type(base_url: str) -> str:
28
28
  """Detect provider type from base URL."""
29
+ if not base_url:
30
+ return "unknown"
29
31
  if "openai.com" in base_url:
30
32
  return "openai"
31
33
  elif ":8880" in base_url:
@@ -47,6 +49,8 @@ def detect_provider_type(base_url: str) -> str:
47
49
 
48
50
  def is_local_provider(base_url: str) -> bool:
49
51
  """Check if a provider URL is for a local service."""
52
+ if not base_url:
53
+ return False
50
54
  provider_type = detect_provider_type(base_url)
51
55
  return provider_type in ["kokoro", "whisper", "local"] or \
52
56
  "127.0.0.1" in base_url or \
@@ -57,13 +61,11 @@ def is_local_provider(base_url: str) -> bool:
57
61
  class EndpointInfo:
58
62
  """Information about a discovered endpoint."""
59
63
  base_url: str
60
- healthy: bool
61
64
  models: List[str]
62
65
  voices: List[str] # Only for TTS
63
- last_health_check: str # ISO format timestamp
64
- response_time_ms: Optional[float] = None
65
- error: Optional[str] = None
66
66
  provider_type: Optional[str] = None # e.g., "openai", "kokoro", "whisper"
67
+ last_check: Optional[str] = None # ISO format timestamp of last attempt
68
+ last_error: Optional[str] = None # Last error if any
67
69
 
68
70
 
69
71
  class ProviderRegistry:
@@ -78,44 +80,38 @@ class ProviderRegistry:
78
80
  self._initialized = False
79
81
 
80
82
  async def initialize(self):
81
- """Initialize the registry by assuming all configured endpoints are healthy."""
83
+ """Initialize the registry with configured endpoints."""
82
84
  if self._initialized:
83
85
  return
84
-
86
+
85
87
  async with self._discovery_lock:
86
88
  if self._initialized: # Double-check after acquiring lock
87
89
  return
88
-
89
- logger.info("Initializing provider registry (optimistic mode)...")
90
-
91
- # Initialize TTS endpoints as healthy
90
+
91
+ logger.info("Initializing provider registry...")
92
+
93
+ # Initialize TTS endpoints
92
94
  for url in TTS_BASE_URLS:
93
95
  provider_type = detect_provider_type(url)
94
96
  self.registry["tts"][url] = EndpointInfo(
95
97
  base_url=url,
96
- healthy=True,
97
98
  models=["gpt4o-mini-tts", "tts-1", "tts-1-hd"] if provider_type == "openai" else ["tts-1"],
98
99
  voices=["alloy", "echo", "fable", "nova", "onyx", "shimmer"] if provider_type == "openai" else ["af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia", "af_jessica", "af_kore", "af_nicole", "af_nova", "af_river", "af_sarah", "af_sky", "af_v0", "af_v0bella", "af_v0irulan", "af_v0nicole", "af_v0sarah", "af_v0sky", "am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam", "am_michael", "am_onyx", "am_puck", "am_santa", "am_v0adam", "am_v0gurney", "am_v0michael", "bf_alice", "bf_emma", "bf_lily", "bf_v0emma", "bf_v0isabella", "bm_daniel", "bm_fable", "bm_george", "bm_lewis", "bm_v0george", "bm_v0lewis", "ef_dora", "em_alex", "em_santa", "ff_siwis", "hf_alpha", "hf_beta", "hm_omega", "hm_psi", "if_sara", "im_nicola", "jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo", "pf_dora", "pm_alex", "pm_santa", "zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi", "zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang"],
99
- last_health_check=datetime.now(timezone.utc).isoformat(),
100
- response_time_ms=None,
101
100
  provider_type=provider_type
102
101
  )
103
102
 
104
- # Initialize STT endpoints as healthy
103
+ # Initialize STT endpoints
105
104
  for url in STT_BASE_URLS:
106
105
  provider_type = detect_provider_type(url)
107
106
  self.registry["stt"][url] = EndpointInfo(
108
107
  base_url=url,
109
- healthy=True,
110
108
  models=["whisper-1"],
111
- voices=[],
112
- last_health_check=datetime.now(timezone.utc).isoformat(),
113
- response_time_ms=None,
109
+ voices=[], # STT doesn't have voices
114
110
  provider_type=provider_type
115
111
  )
116
-
112
+
117
113
  self._initialized = True
118
- logger.info(f"Provider registry initialized with {len(self.registry['tts'])} TTS and {len(self.registry['stt'])} STT endpoints (all assumed healthy)")
114
+ logger.info(f"Provider registry initialized with {len(self.registry['tts'])} TTS and {len(self.registry['stt'])} STT endpoints")
119
115
 
120
116
  async def _discover_endpoints(self, service_type: str, base_urls: List[str]):
121
117
  """Discover all endpoints for a service type."""
@@ -131,12 +127,11 @@ class ProviderRegistry:
131
127
  logger.error(f"Failed to discover {service_type} endpoint {url}: {result}")
132
128
  self.registry[service_type][url] = EndpointInfo(
133
129
  base_url=url,
134
- healthy=False,
135
130
  models=[],
136
131
  voices=[],
137
- last_health_check=datetime.now(timezone.utc).isoformat(),
138
- error=str(result),
139
- provider_type=detect_provider_type(url)
132
+ provider_type=detect_provider_type(url),
133
+ last_check=datetime.now(timezone.utc).isoformat(),
134
+ last_error=str(result)
140
135
  )
141
136
 
142
137
  async def _discover_endpoint(self, service_type: str, base_url: str) -> None:
@@ -201,12 +196,11 @@ class ProviderRegistry:
201
196
  # Store endpoint info
202
197
  self.registry[service_type][base_url] = EndpointInfo(
203
198
  base_url=base_url,
204
- healthy=True,
205
199
  models=models,
206
200
  voices=voices,
207
- last_health_check=datetime.now(timezone.utc).isoformat(),
208
- response_time_ms=response_time,
209
- provider_type=detect_provider_type(base_url)
201
+ provider_type=detect_provider_type(base_url),
202
+ last_check=datetime.now(timezone.utc).isoformat(),
203
+ last_error=None
210
204
  )
211
205
 
212
206
  logger.info(f"Successfully discovered {service_type} endpoint {base_url} with {len(models)} models and {len(voices)} voices")
@@ -215,12 +209,11 @@ class ProviderRegistry:
215
209
  logger.warning(f"Endpoint {base_url} discovery failed: {e}")
216
210
  self.registry[service_type][base_url] = EndpointInfo(
217
211
  base_url=base_url,
218
- healthy=False,
219
212
  models=[],
220
213
  voices=[],
221
- last_health_check=datetime.now(timezone.utc).isoformat(),
222
- error=str(e),
223
- provider_type=detect_provider_type(base_url)
214
+ provider_type=detect_provider_type(base_url),
215
+ last_check=datetime.now(timezone.utc).isoformat(),
216
+ last_error=str(e)
224
217
  )
225
218
 
226
219
  async def _discover_voices(self, base_url: str, client: AsyncOpenAI) -> List[str]:
@@ -247,41 +240,35 @@ class ProviderRegistry:
247
240
  # The system will use configured defaults instead
248
241
  return []
249
242
 
250
- async def check_health(self, service_type: str, base_url: str) -> bool:
251
- """Check the health of a specific endpoint and update registry."""
252
- logger.debug(f"Health check for {service_type} endpoint: {base_url}")
253
-
254
- # Re-discover the endpoint
255
- await self._discover_endpoint(service_type, base_url)
256
-
257
- # Return health status
258
- endpoint_info = self.registry[service_type].get(base_url)
259
- return endpoint_info.healthy if endpoint_info else False
260
243
 
261
- def get_healthy_endpoints(self, service_type: str) -> List[EndpointInfo]:
262
- """Get all healthy endpoints for a service type."""
244
+ def get_endpoints(self, service_type: str) -> List[EndpointInfo]:
245
+ """Get all endpoints for a service type in priority order."""
263
246
  endpoints = []
264
-
247
+
265
248
  # Return endpoints in the order they were configured
266
249
  base_urls = TTS_BASE_URLS if service_type == "tts" else STT_BASE_URLS
267
-
250
+
268
251
  for url in base_urls:
269
252
  info = self.registry[service_type].get(url)
270
- if info and info.healthy:
253
+ if info:
271
254
  endpoints.append(info)
272
-
255
+
273
256
  return endpoints
257
+
258
+ def get_healthy_endpoints(self, service_type: str) -> List[EndpointInfo]:
259
+ """Deprecated: Use get_endpoints instead. Returns all endpoints."""
260
+ return self.get_endpoints(service_type)
274
261
 
275
262
  def find_endpoint_with_voice(self, voice: str) -> Optional[EndpointInfo]:
276
- """Find the first healthy TTS endpoint that supports a specific voice."""
277
- for endpoint in self.get_healthy_endpoints("tts"):
263
+ """Find the first TTS endpoint that supports a specific voice."""
264
+ for endpoint in self.get_endpoints("tts"):
278
265
  if voice in endpoint.voices:
279
266
  return endpoint
280
267
  return None
281
-
268
+
282
269
  def find_endpoint_with_model(self, service_type: str, model: str) -> Optional[EndpointInfo]:
283
- """Find the first healthy endpoint that supports a specific model."""
284
- for endpoint in self.get_healthy_endpoints(service_type):
270
+ """Find the first endpoint that supports a specific model."""
271
+ for endpoint in self.get_endpoints(service_type):
285
272
  if model in endpoint.models:
286
273
  return endpoint
287
274
  return None
@@ -291,47 +278,36 @@ class ProviderRegistry:
291
278
  return {
292
279
  "tts": {
293
280
  url: {
294
- "healthy": info.healthy,
295
281
  "models": info.models,
296
282
  "voices": info.voices,
297
- "response_time_ms": info.response_time_ms,
298
- "last_check": info.last_health_check,
299
- "error": info.error
283
+ "provider_type": info.provider_type,
284
+ "last_check": info.last_check,
285
+ "last_error": info.last_error
300
286
  }
301
287
  for url, info in self.registry["tts"].items()
302
288
  },
303
289
  "stt": {
304
290
  url: {
305
- "healthy": info.healthy,
306
291
  "models": info.models,
307
- "response_time_ms": info.response_time_ms,
308
- "last_check": info.last_health_check,
309
- "error": info.error
292
+ "provider_type": info.provider_type,
293
+ "last_check": info.last_check,
294
+ "last_error": info.last_error
310
295
  }
311
296
  for url, info in self.registry["stt"].items()
312
297
  }
313
298
  }
314
299
 
315
- async def mark_unhealthy(self, service_type: str, base_url: str, error: str):
316
- """Mark an endpoint as unhealthy after a failure.
317
-
318
- If ALWAYS_TRY_LOCAL is enabled and the provider is local, it will not be
319
- permanently marked as unhealthy - it will be retried on next request.
300
+ async def mark_failed(self, service_type: str, base_url: str, error: str):
301
+ """Record that an endpoint failed.
302
+
303
+ This updates the last_error and last_check fields for diagnostics,
304
+ but doesn't prevent the endpoint from being tried again.
320
305
  """
321
306
  if base_url in self.registry[service_type]:
322
- # Check if we should skip marking local providers as unhealthy
323
- if config.ALWAYS_TRY_LOCAL and is_local_provider(base_url):
324
- # Log the error but don't mark as unhealthy
325
- logger.info(f"Local {service_type} endpoint {base_url} failed ({error}) but will be retried (ALWAYS_TRY_LOCAL enabled)")
326
- # Update error and last check time for diagnostics, but keep healthy=True
327
- self.registry[service_type][base_url].error = f"{error} (will retry)"
328
- self.registry[service_type][base_url].last_health_check = datetime.now(timezone.utc).isoformat()
329
- else:
330
- # Normal behavior - mark as unhealthy
331
- self.registry[service_type][base_url].healthy = False
332
- self.registry[service_type][base_url].error = error
333
- self.registry[service_type][base_url].last_health_check = datetime.now(timezone.utc).isoformat()
334
- logger.warning(f"Marked {service_type} endpoint {base_url} as unhealthy: {error}")
307
+ # Update error and last check time for diagnostics
308
+ self.registry[service_type][base_url].last_error = error
309
+ self.registry[service_type][base_url].last_check = datetime.now(timezone.utc).isoformat()
310
+ logger.info(f"{service_type} endpoint {base_url} failed: {error}")
335
311
 
336
312
 
337
313
  # Global registry instance