voice-mode 4.1.0__py3-none-any.whl → 4.3.1__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 (73) hide show
  1. voice_mode/__version__.py +1 -1
  2. voice_mode/cli.py +91 -3
  3. voice_mode/cli_commands/claude.py +208 -0
  4. voice_mode/cli_commands/hook.py +209 -0
  5. voice_mode/config.py +114 -0
  6. voice_mode/frontend/.next/BUILD_ID +1 -1
  7. voice_mode/frontend/.next/app-build-manifest.json +5 -5
  8. voice_mode/frontend/.next/build-manifest.json +3 -3
  9. voice_mode/frontend/.next/next-minimal-server.js.nft.json +1 -1
  10. voice_mode/frontend/.next/next-server.js.nft.json +1 -1
  11. voice_mode/frontend/.next/prerender-manifest.json +1 -1
  12. voice_mode/frontend/.next/required-server-files.json +1 -1
  13. voice_mode/frontend/.next/server/app/_not-found/page.js +1 -1
  14. voice_mode/frontend/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. voice_mode/frontend/.next/server/app/_not-found.html +1 -1
  16. voice_mode/frontend/.next/server/app/_not-found.rsc +1 -1
  17. voice_mode/frontend/.next/server/app/api/connection-details/route.js +2 -2
  18. voice_mode/frontend/.next/server/app/favicon.ico/route.js +2 -2
  19. voice_mode/frontend/.next/server/app/index.html +1 -1
  20. voice_mode/frontend/.next/server/app/index.rsc +2 -2
  21. voice_mode/frontend/.next/server/app/page.js +3 -3
  22. voice_mode/frontend/.next/server/app/page_client-reference-manifest.js +1 -1
  23. voice_mode/frontend/.next/server/chunks/994.js +2 -2
  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/pages-manifest.json +1 -1
  30. voice_mode/frontend/.next/server/server-reference-manifest.json +1 -1
  31. voice_mode/frontend/.next/standalone/.next/BUILD_ID +1 -1
  32. voice_mode/frontend/.next/standalone/.next/app-build-manifest.json +5 -5
  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 +3 -3
  45. voice_mode/frontend/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  46. voice_mode/frontend/.next/standalone/.next/server/chunks/994.js +2 -2
  47. voice_mode/frontend/.next/standalone/.next/server/middleware-build-manifest.js +1 -1
  48. voice_mode/frontend/.next/standalone/.next/server/next-font-manifest.js +1 -1
  49. voice_mode/frontend/.next/standalone/.next/server/next-font-manifest.json +1 -1
  50. voice_mode/frontend/.next/standalone/.next/server/pages/404.html +1 -1
  51. voice_mode/frontend/.next/standalone/.next/server/pages/500.html +1 -1
  52. voice_mode/frontend/.next/standalone/.next/server/pages-manifest.json +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-fcb9b9ba5b72c7fc.js → layout-b52135f331b36caf.js} +1 -1
  56. voice_mode/frontend/.next/static/chunks/app/{page-7c7ec2ad413ace39.js → page-43b7b45e01f0aa87.js} +1 -1
  57. voice_mode/frontend/.next/static/chunks/{main-app-d02bd38ac01adb8a.js → main-app-80dc9078926fc708.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 +3 -3
  63. voice_mode/tools/claude_thinking.py +285 -0
  64. voice_mode/tools/sound_fonts/__init__.py +1 -0
  65. voice_mode/tools/sound_fonts/audio_player.py +87 -0
  66. voice_mode/tools/sound_fonts/hook_handler.py +127 -0
  67. voice_mode/tools/sound_fonts/player.py +180 -0
  68. {voice_mode-4.1.0.dist-info → voice_mode-4.3.1.dist-info}/METADATA +1 -1
  69. {voice_mode-4.1.0.dist-info → voice_mode-4.3.1.dist-info}/RECORD +73 -66
  70. /voice_mode/frontend/.next/static/{pbDjheefW1LwCua_8mPoZ → uZHYPdRYOObTZ3MQHekp0}/_buildManifest.js +0 -0
  71. /voice_mode/frontend/.next/static/{pbDjheefW1LwCua_8mPoZ → uZHYPdRYOObTZ3MQHekp0}/_ssgManifest.js +0 -0
  72. {voice_mode-4.1.0.dist-info → voice_mode-4.3.1.dist-info}/WHEEL +0 -0
  73. {voice_mode-4.1.0.dist-info → voice_mode-4.3.1.dist-info}/entry_points.txt +0 -0
voice_mode/__version__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  # This file is automatically updated by 'make release'
2
2
  # Do not edit manually
3
- __version__ = "4.1.0"
3
+ __version__ = "4.3.1"
voice_mode/cli.py CHANGED
@@ -57,6 +57,14 @@ def voice_mode() -> None:
57
57
  voice_mode_main_cli()
58
58
 
59
59
 
60
+ # Audio group for audio-related commands
61
+ @voice_mode_main_cli.group()
62
+ @click.help_option('-h', '--help', help='Show this message and exit')
63
+ def audio():
64
+ """Audio transcription and playback commands."""
65
+ pass
66
+
67
+
60
68
  # Service group commands
61
69
  @voice_mode_main_cli.group()
62
70
  @click.help_option('-h', '--help', help='Show this message and exit')
@@ -1361,19 +1369,36 @@ def cli():
1361
1369
  from voice_mode.cli_commands import exchanges as exchanges_cmd
1362
1370
  from voice_mode.cli_commands import transcribe as transcribe_cmd
1363
1371
  from voice_mode.cli_commands import pronounce_commands
1372
+ from voice_mode.cli_commands import claude
1373
+ from voice_mode.cli_commands import hook as hook_cmd
1364
1374
 
1365
1375
  # Add subcommands to legacy CLI
1366
1376
  cli.add_command(exchanges_cmd.exchanges)
1367
1377
  cli.add_command(transcribe_cmd.transcribe)
1368
1378
  cli.add_command(pronounce_commands.pronounce_group)
1379
+ cli.add_command(claude.claude_group)
1369
1380
 
1370
1381
  # Add exchanges to main CLI
1371
1382
  voice_mode_main_cli.add_command(exchanges_cmd.exchanges)
1372
- voice_mode_main_cli.add_command(pronounce_commands.pronounce_group)
1383
+ voice_mode_main_cli.add_command(claude.claude_group)
1384
+
1385
+ # Note: We'll add these commands after the groups are defined
1386
+ # audio group will get transcribe and play commands
1387
+ # claude group will get hook command
1388
+ # config group will get pronounce command
1373
1389
 
1374
- # Add transcribe to main CLI
1375
- voice_mode_main_cli.add_command(transcribe_cmd.transcribe)
1376
1390
 
1391
+ # Now add the subcommands to their respective groups
1392
+ # Add transcribe command to audio group
1393
+ transcribe_audio_cmd = transcribe_cmd.transcribe.commands['audio']
1394
+ transcribe_audio_cmd.name = 'transcribe'
1395
+ audio.add_command(transcribe_audio_cmd)
1396
+
1397
+ # Add hook command under claude group
1398
+ claude.claude_group.add_command(hook_cmd.hook)
1399
+
1400
+ # Add pronounce under config group
1401
+ config.add_command(pronounce_commands.pronounce_group)
1377
1402
 
1378
1403
  # Converse command - direct voice conversation from CLI
1379
1404
  @voice_mode_main_cli.command()
@@ -1751,6 +1776,69 @@ def update(force):
1751
1776
  click.echo("Try running: pip install --upgrade voice-mode")
1752
1777
 
1753
1778
 
1779
+ # Sound Fonts command
1780
+ @audio.command("play")
1781
+ @click.help_option('-h', '--help')
1782
+ @click.option('-t', '--tool', help='Tool name for direct command-line usage')
1783
+ @click.option('-a', '--action', default='start', type=click.Choice(['start', 'end']), help='Action type')
1784
+ @click.option('-s', '--subagent', help='Subagent type (for Task tool)')
1785
+ def play_sound(tool, action, subagent):
1786
+ """Play sound based on tool events (primarily for Claude Code hooks).
1787
+
1788
+ This command is designed to be called by Claude Code hooks to play sounds
1789
+ when tools are used. It reads hook data from stdin by default, or can be
1790
+ used directly with command-line options.
1791
+
1792
+ Examples:
1793
+ echo '{"tool_name":"Task","tool_input":{"subagent_type":"mama-bear"}}' | voicemode play-sound
1794
+ voicemode play-sound --tool Task --action start --subagent mama-bear
1795
+ """
1796
+ import sys
1797
+ from .tools.sound_fonts.player import AudioPlayer
1798
+ from .tools.sound_fonts.hook_handler import (
1799
+ read_hook_data_from_stdin,
1800
+ parse_claude_code_hook
1801
+ )
1802
+
1803
+ # Try to read hook data from stdin first
1804
+ hook_data = None
1805
+ if not sys.stdin.isatty():
1806
+ hook_data = read_hook_data_from_stdin()
1807
+
1808
+ if hook_data:
1809
+ # Parse Claude Code hook format
1810
+ parsed_data = parse_claude_code_hook(hook_data)
1811
+ if not parsed_data:
1812
+ sys.exit(1)
1813
+
1814
+ tool_name = parsed_data["tool_name"]
1815
+ action_type = parsed_data["action"]
1816
+ subagent_type = parsed_data["subagent_type"]
1817
+ metadata = parsed_data["metadata"]
1818
+ else:
1819
+ # Use command-line arguments
1820
+ if not tool:
1821
+ click.echo("Error: --tool is required when not reading from stdin", err=True)
1822
+ sys.exit(1)
1823
+
1824
+ tool_name = tool
1825
+ action_type = action
1826
+ subagent_type = subagent
1827
+ metadata = {}
1828
+
1829
+ # Play the sound
1830
+ player = AudioPlayer()
1831
+ success = player.play_sound_for_event(
1832
+ tool_name=tool_name,
1833
+ action=action_type,
1834
+ subagent_type=subagent_type,
1835
+ metadata=metadata
1836
+ )
1837
+
1838
+ # Silent exit for hooks - don't clutter Claude Code output
1839
+ sys.exit(0 if success else 1)
1840
+
1841
+
1754
1842
  # Completions command
1755
1843
  @voice_mode_main_cli.command()
1756
1844
  @click.help_option('-h', '--help')
@@ -0,0 +1,208 @@
1
+ """CLI commands for Claude Code message extraction."""
2
+
3
+ import click
4
+ import json
5
+ import os
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Optional, List
9
+ from datetime import datetime
10
+
11
+ from voice_mode.tools.claude_thinking import (
12
+ find_claude_log_file,
13
+ extract_messages_from_log,
14
+ extract_thinking_from_messages
15
+ )
16
+
17
+ logger = logging.getLogger("voice-mode")
18
+
19
+
20
+ @click.group(name='claude')
21
+ def claude_group():
22
+ """Extract messages from Claude Code conversation logs."""
23
+ pass
24
+
25
+
26
+ @claude_group.command(name='messages')
27
+ @click.option('--last-n', '-n', default=2, type=int,
28
+ help='Number of recent messages to extract (default: 2)')
29
+ @click.option('--type', '-t', 'message_types', multiple=True,
30
+ type=click.Choice(['user', 'assistant']),
31
+ help='Filter by message type (can specify multiple)')
32
+ @click.option('--format', '-f',
33
+ type=click.Choice(['full', 'text', 'thinking']),
34
+ default='full',
35
+ help='Output format: full (complete), text (text only), thinking (thinking only)')
36
+ @click.option('--working-dir', '-d', type=click.Path(exists=True),
37
+ help='Working directory to find logs for (defaults to CWD)')
38
+ @click.option('--output', '-o', type=click.Path(),
39
+ help='Save output to file')
40
+ @click.option('--json', 'as_json', is_flag=True,
41
+ help='Output as JSON (overrides format)')
42
+ def messages_command(last_n: int, message_types: tuple, format: str,
43
+ working_dir: Optional[str], output: Optional[str],
44
+ as_json: bool):
45
+ """
46
+ Extract recent messages from Claude Code logs.
47
+
48
+ Examples:
49
+
50
+ voicemode claude messages
51
+
52
+ voicemode claude messages -n 5 --format thinking
53
+
54
+ voicemode claude messages --type assistant --format text
55
+
56
+ voicemode claude messages --json -o messages.json
57
+ """
58
+ # Find log file
59
+ log_file = find_claude_log_file(working_dir)
60
+ if not log_file:
61
+ click.echo(f"Error: Could not find Claude Code logs for directory: {working_dir or os.getcwd()}", err=True)
62
+ return
63
+
64
+ # Extract messages
65
+ message_type_list = list(message_types) if message_types else None
66
+ messages = extract_messages_from_log(log_file, last_n, message_type_list)
67
+
68
+ if not messages:
69
+ click.echo("No messages found in recent logs", err=True)
70
+ return
71
+
72
+ # Format output
73
+ if as_json:
74
+ content = json.dumps(messages, indent=2)
75
+ elif format == 'thinking':
76
+ thinking_texts = extract_thinking_from_messages(messages)
77
+ if not thinking_texts:
78
+ click.echo("No thinking content found", err=True)
79
+ return
80
+ content = "\n\n=== Next Thinking ===\n\n".join(thinking_texts)
81
+ elif format == 'text':
82
+ result = []
83
+ for msg in messages:
84
+ content_text = []
85
+ content_items = msg.get('content', [])
86
+ logger.debug(f"Message has {len(content_items)} content items")
87
+ for item in content_items:
88
+ if isinstance(item, dict):
89
+ item_type = item.get('type')
90
+ logger.debug(f"Content item type: {item_type}")
91
+ if item_type == 'text':
92
+ text = item.get('text', '')
93
+ if text:
94
+ content_text.append(text)
95
+ elif item_type == 'thinking':
96
+ text = item.get('text', '')
97
+ if text:
98
+ content_text.append(f"[Thinking: {text}]")
99
+ if content_text:
100
+ result.append(f"{msg['type'].title()}: {' '.join(content_text)}")
101
+ content = "\n\n".join(result)
102
+ else: # full format
103
+ # Format as human-readable
104
+ result = []
105
+ for i, msg in enumerate(messages, 1):
106
+ result.append(f"=== Message {i} ===")
107
+ result.append(f"Type: {msg['type']}")
108
+ result.append(f"Timestamp: {msg.get('timestamp', 'Unknown')}")
109
+ if msg.get('model'):
110
+ result.append(f"Model: {msg['model']}")
111
+
112
+ # Format content
113
+ content_items = msg.get('content', [])
114
+ if content_items:
115
+ result.append("Content:")
116
+ for item in content_items:
117
+ if isinstance(item, dict):
118
+ item_type = item.get('type', 'unknown')
119
+ if item_type == 'text':
120
+ result.append(f" [Text]: {item.get('text', '')}")
121
+ elif item_type == 'thinking':
122
+ result.append(f" [Thinking]: {item.get('text', '')}")
123
+ elif item_type == 'tool_use':
124
+ result.append(f" [Tool Use]: {item.get('name', '')}")
125
+ elif item_type == 'tool_result':
126
+ result.append(f" [Tool Result]: {item.get('content', '')[:100]}...")
127
+ result.append("")
128
+ content = "\n".join(result).strip()
129
+
130
+ # Output
131
+ if output:
132
+ Path(output).write_text(content)
133
+ click.echo(f"Output saved to {output}")
134
+ else:
135
+ # Debug: ensure content is printed
136
+ logger.debug(f"About to output {len(content)} characters")
137
+ click.echo(content)
138
+ logger.debug("Output complete")
139
+
140
+
141
+ @claude_group.command(name='thinking')
142
+ @click.option('--last-n', '-n', default=1, type=int,
143
+ help='Number of messages to search for thinking (default: 1)')
144
+ @click.option('--working-dir', '-d', type=click.Path(exists=True),
145
+ help='Working directory to find logs for (defaults to CWD)')
146
+ def thinking_command(last_n: int, working_dir: Optional[str]):
147
+ """
148
+ Extract only thinking content from Claude Code logs.
149
+
150
+ Convenience command equivalent to:
151
+ voicemode claude messages --format thinking --type assistant
152
+
153
+ Examples:
154
+
155
+ voicemode claude thinking
156
+
157
+ voicemode claude thinking -n 3
158
+ """
159
+ # Delegate to messages command with thinking format
160
+ ctx = click.get_current_context()
161
+ ctx.invoke(messages_command,
162
+ last_n=last_n,
163
+ message_types=('assistant',),
164
+ format='thinking',
165
+ working_dir=working_dir,
166
+ output=None,
167
+ as_json=False)
168
+
169
+
170
+ @claude_group.command(name='check')
171
+ def check_command():
172
+ """
173
+ Check if Claude Code context is available.
174
+
175
+ Shows information about the Claude Code environment including:
176
+ - Whether Claude Code logs are accessible
177
+ - Current working directory
178
+ - Log file location if found
179
+ - Last update time
180
+
181
+ Example:
182
+ voicemode claude check
183
+ """
184
+ working_dir = os.getcwd()
185
+ log_file = find_claude_log_file(working_dir)
186
+
187
+ click.echo(f"Working Directory: {working_dir}")
188
+ click.echo(f"Claude Logs Found: {'Yes' if log_file else 'No'}")
189
+
190
+ if log_file:
191
+ click.echo(f"Log File: {log_file}")
192
+ click.echo(f"Log Size: {log_file.stat().st_size:,} bytes")
193
+
194
+ mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
195
+ now = datetime.now()
196
+ age = now - mtime
197
+
198
+ if age.total_seconds() < 60:
199
+ click.echo(f"Last Updated: {int(age.total_seconds())} seconds ago")
200
+ elif age.total_seconds() < 3600:
201
+ click.echo(f"Last Updated: {int(age.total_seconds() / 60)} minutes ago")
202
+ else:
203
+ click.echo(f"Last Updated: {int(age.total_seconds() / 3600)} hours ago")
204
+ else:
205
+ project_dir = working_dir.replace('/', '-')
206
+ expected_path = Path.home() / '.claude' / 'projects' / project_dir
207
+ click.echo(f"Expected Log Location: {expected_path}")
208
+ click.echo("Note: Logs are only created when using Claude Code (claude.ai/code)")
@@ -0,0 +1,209 @@
1
+ """
2
+ Hook commands for Voice Mode - primarily for Claude Code integration.
3
+ """
4
+
5
+ import click
6
+ import sys
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+
13
+ @click.group()
14
+ @click.help_option('-h', '--help', help='Show this message and exit')
15
+ def hook():
16
+ """Manage Voice Mode hooks and event handlers."""
17
+ pass
18
+
19
+
20
+ @hook.command('stdin-receiver')
21
+ @click.option('--tool-name', help='Override tool name (for testing)')
22
+ @click.option('--action', type=click.Choice(['start', 'end']), help='Override action (for testing)')
23
+ @click.option('--subagent-type', help='Override subagent type (for testing)')
24
+ @click.option('--event', type=click.Choice(['PreToolUse', 'PostToolUse']), help='Override event (for testing)')
25
+ @click.option('--debug', is_flag=True, help='Enable debug output')
26
+ def stdin_receiver(tool_name, action, subagent_type, event, debug):
27
+ """Receive and process hook events from Claude Code via stdin.
28
+
29
+ This command reads JSON from stdin when called by Claude Code hooks,
30
+ or accepts command-line arguments for testing.
31
+
32
+ The filesystem structure defines sound mappings:
33
+ ~/.voicemode/soundfonts/current/PreToolUse/task/subagent/baby-bear.wav
34
+
35
+ Examples:
36
+ # Called by Claude Code (reads JSON from stdin)
37
+ voicemode hook stdin-receiver
38
+
39
+ # Testing with defaults
40
+ voicemode hook stdin-receiver --debug
41
+
42
+ # Testing with specific values
43
+ voicemode hook stdin-receiver --tool-name Task --action start --subagent-type mama-bear
44
+ """
45
+ from voice_mode.tools.sound_fonts.audio_player import Player
46
+
47
+ # Try to read JSON from stdin if available
48
+ hook_data = {}
49
+ if not sys.stdin.isatty():
50
+ try:
51
+ hook_data = json.load(sys.stdin)
52
+ if debug:
53
+ print(f"[DEBUG] Received JSON: {json.dumps(hook_data, indent=2)}", file=sys.stderr)
54
+ except Exception as e:
55
+ if debug:
56
+ print(f"[DEBUG] Failed to parse JSON from stdin: {e}", file=sys.stderr)
57
+ # Silent fail for hooks
58
+ sys.exit(0)
59
+
60
+ # Extract values from JSON or use command-line overrides/defaults
61
+ if not tool_name:
62
+ tool_name = hook_data.get('tool_name', 'Task')
63
+
64
+ if not event:
65
+ event_name = hook_data.get('hook_event_name', 'PreToolUse')
66
+ else:
67
+ event_name = event
68
+
69
+ # Map event to action if not specified
70
+ if not action:
71
+ if event_name == 'PreToolUse':
72
+ action = 'start'
73
+ elif event_name == 'PostToolUse':
74
+ action = 'end'
75
+ else:
76
+ action = 'start' # Default
77
+
78
+ # Get subagent_type from tool_input if not specified
79
+ if not subagent_type and tool_name == 'Task':
80
+ tool_input = hook_data.get('tool_input', {})
81
+ subagent_type = tool_input.get('subagent_type', 'baby-bear')
82
+ elif not subagent_type:
83
+ subagent_type = None
84
+
85
+ if debug:
86
+ print(f"[DEBUG] Processing: event={event_name}, tool={tool_name}, "
87
+ f"action={action}, subagent={subagent_type}", file=sys.stderr)
88
+
89
+ # Check if sound fonts are enabled
90
+ from voice_mode.config import SOUNDFONTS_ENABLED
91
+
92
+ if not SOUNDFONTS_ENABLED:
93
+ if debug:
94
+ print(f"[DEBUG] Sound fonts are disabled (VOICEMODE_SOUNDFONTS_ENABLED=false)", file=sys.stderr)
95
+ else:
96
+ # Find sound file using filesystem conventions
97
+ sound_file = find_sound_file(event_name, tool_name, subagent_type)
98
+
99
+ if sound_file:
100
+ if debug:
101
+ print(f"[DEBUG] Found sound file: {sound_file}", file=sys.stderr)
102
+
103
+ # Play the sound
104
+ player = Player()
105
+ success = player.play(str(sound_file))
106
+
107
+ if debug:
108
+ if success:
109
+ print(f"[DEBUG] Sound played successfully", file=sys.stderr)
110
+ else:
111
+ print(f"[DEBUG] Failed to play sound", file=sys.stderr)
112
+ else:
113
+ if debug:
114
+ print(f"[DEBUG] No sound file found for this event", file=sys.stderr)
115
+
116
+ # Always exit 0 to not disrupt Claude Code
117
+ sys.exit(0)
118
+
119
+
120
+ def find_sound_file(event: str, tool: str, subagent: Optional[str] = None) -> Optional[Path]:
121
+ """
122
+ Find sound file using filesystem conventions.
123
+
124
+ Tries paths in order (mp3 preferred over wav for size):
125
+ 1. Most specific: {event}/{tool}/subagent/{subagent}.{mp3,wav} (Task tool only)
126
+ 2. Tool default: {event}/{tool}/default.{mp3,wav}
127
+ 3. Event default: {event}/default.{mp3,wav}
128
+ 4. Global fallback: fallback.{mp3,wav}
129
+
130
+ Args:
131
+ event: Event name (PreToolUse, PostToolUse)
132
+ tool: Tool name (lowercase)
133
+ subagent: Optional subagent type (lowercase)
134
+
135
+ Returns:
136
+ Path to sound file if found, None otherwise
137
+ """
138
+ # Get base path (follow symlink if exists)
139
+ base_path = Path.home() / '.voicemode' / 'soundfonts' / 'current'
140
+
141
+ # Resolve symlink if it exists
142
+ if base_path.is_symlink():
143
+ base_path = base_path.resolve()
144
+
145
+ if not base_path.exists():
146
+ return None
147
+
148
+ # Normalize names to lowercase for filesystem
149
+ event = event.lower() if event else 'pretooluse'
150
+ tool = tool.lower() if tool else 'default'
151
+ subagent = subagent.lower() if subagent else None
152
+
153
+ # Map event names to directory names
154
+ event_map = {
155
+ 'pretooluse': 'PreToolUse',
156
+ 'posttooluse': 'PostToolUse',
157
+ 'start': 'PreToolUse',
158
+ 'end': 'PostToolUse'
159
+ }
160
+ event_dir = event_map.get(event, event)
161
+
162
+ # Build list of paths to try (most specific to least specific)
163
+ paths_to_try = []
164
+
165
+ # 1. Most specific: subagent sound (Task tool only)
166
+ if tool == 'task' and subagent:
167
+ # Try mp3 first (smaller), then wav
168
+ paths_to_try.append(base_path / event_dir / tool / 'subagent' / f'{subagent}.mp3')
169
+ paths_to_try.append(base_path / event_dir / tool / 'subagent' / f'{subagent}.wav')
170
+
171
+ # 2. Tool-specific default
172
+ paths_to_try.append(base_path / event_dir / tool / 'default.mp3')
173
+ paths_to_try.append(base_path / event_dir / tool / 'default.wav')
174
+
175
+ # 3. Event-level default
176
+ paths_to_try.append(base_path / event_dir / 'default.mp3')
177
+ paths_to_try.append(base_path / event_dir / 'default.wav')
178
+
179
+ # 4. Global fallback
180
+ paths_to_try.append(base_path / 'fallback.mp3')
181
+ paths_to_try.append(base_path / 'fallback.wav')
182
+
183
+ # Find first existing file
184
+ for path in paths_to_try:
185
+ if path.exists():
186
+ return path
187
+
188
+ return None
189
+
190
+
191
+ # Keep the old receiver command for backwards compatibility (deprecated)
192
+ @hook.command('receiver', hidden=True)
193
+ @click.argument('tool_name')
194
+ @click.argument('action', type=click.Choice(['start', 'end', 'complete']))
195
+ @click.argument('subagent_type', required=False)
196
+ @click.option('--debug', is_flag=True, help='Enable debug output')
197
+ def receiver_deprecated(tool_name, action, subagent_type, debug):
198
+ """[DEPRECATED] Use stdin-receiver instead."""
199
+ # Map old action to event
200
+ event = 'PreToolUse' if action == 'start' else 'PostToolUse'
201
+
202
+ # Call the new command
203
+ ctx = click.get_current_context()
204
+ ctx.invoke(stdin_receiver,
205
+ tool_name=tool_name,
206
+ action=action if action != 'complete' else 'end',
207
+ subagent_type=subagent_type,
208
+ event=event,
209
+ debug=debug)
voice_mode/config.py CHANGED
@@ -101,6 +101,9 @@ def load_voicemode_env():
101
101
  # Enable audio feedback (true/false)
102
102
  # VOICEMODE_AUDIO_FEEDBACK=true
103
103
 
104
+ # Enable sound fonts for tool use hooks (true/false)
105
+ # VOICEMODE_SOUNDFONTS_ENABLED=false
106
+
104
107
  #############
105
108
  # Provider Configuration
106
109
  #############
@@ -371,6 +374,11 @@ FRONTEND_PORT = int(os.getenv("VOICEMODE_FRONTEND_PORT", "3000"))
371
374
  # Auto-enable services after installation
372
375
  SERVICE_AUTO_ENABLE = env_bool("VOICEMODE_SERVICE_AUTO_ENABLE", False)
373
376
 
377
+ # ==================== SOUND FONTS CONFIGURATION ====================
378
+
379
+ # Sound fonts are disabled by default to avoid annoying users with unexpected sounds
380
+ SOUNDFONTS_ENABLED = env_bool("VOICEMODE_SOUNDFONTS_ENABLED", False)
381
+
374
382
  # ==================== AUDIO CONFIGURATION ====================
375
383
 
376
384
  # Audio parameters
@@ -555,6 +563,87 @@ def initialize_directories():
555
563
  # Create events log directory
556
564
  if EVENT_LOG_ENABLED:
557
565
  Path(EVENT_LOG_DIR).mkdir(parents=True, exist_ok=True)
566
+
567
+ # Initialize sound fonts if not present
568
+ initialize_soundfonts()
569
+
570
+ # ==================== SOUND FONTS INITIALIZATION ====================
571
+
572
+ def initialize_soundfonts():
573
+ """Install default sound fonts from package data if not present."""
574
+ import shutil
575
+ import importlib.resources
576
+
577
+ soundfonts_dir = BASE_DIR / "soundfonts"
578
+ default_soundfont_dir = soundfonts_dir / "default"
579
+ current_symlink = soundfonts_dir / "current"
580
+
581
+ # Skip if soundfonts already exist (user has customized them)
582
+ if default_soundfont_dir.exists():
583
+ # Ensure symlink exists if directory exists
584
+ if not current_symlink.exists():
585
+ try:
586
+ current_symlink.symlink_to(default_soundfont_dir.resolve())
587
+ except OSError:
588
+ # Symlinks might not work on all systems
589
+ pass
590
+ return
591
+
592
+ try:
593
+ # Create soundfonts directory
594
+ soundfonts_dir.mkdir(exist_ok=True)
595
+
596
+ # Copy default soundfonts from package data
597
+ try:
598
+ # For Python 3.9+
599
+ from importlib.resources import files
600
+ package_soundfonts = files("voice_mode.data.soundfonts.default")
601
+
602
+ if package_soundfonts.is_dir():
603
+ # Create the default directory
604
+ default_soundfont_dir.mkdir(exist_ok=True)
605
+
606
+ # Recursively copy all files from package data
607
+ def copy_tree(src, dst):
608
+ """Recursively copy directory tree from package data."""
609
+ dst.mkdir(exist_ok=True)
610
+ for item in src.iterdir():
611
+ if item.is_file():
612
+ target = dst / item.name
613
+ target.write_bytes(item.read_bytes())
614
+ elif item.is_dir():
615
+ copy_tree(item, dst / item.name)
616
+
617
+ # Copy entire tree structure
618
+ copy_tree(package_soundfonts, default_soundfont_dir)
619
+ except ImportError:
620
+ # Fallback for older Python versions
621
+ import pkg_resources
622
+
623
+ # Create the default directory
624
+ default_soundfont_dir.mkdir(exist_ok=True)
625
+
626
+ # List all resources in the soundfonts directory
627
+ resource_dir = "data/soundfonts/default"
628
+ if pkg_resources.resource_exists("voice_mode", resource_dir):
629
+ # This is a bit more complex with pkg_resources
630
+ # We'll need to manually copy the structure
631
+ pass
632
+
633
+ # Create symlink to current soundfont (points to default)
634
+ if default_soundfont_dir.exists() and not current_symlink.exists():
635
+ try:
636
+ current_symlink.symlink_to(default_soundfont_dir.resolve())
637
+ except OSError:
638
+ # Symlinks might not work on all systems (e.g., Windows without admin)
639
+ pass
640
+
641
+ except Exception as e:
642
+ # Don't fail initialization if soundfonts can't be installed
643
+ # They're optional and disabled by default
644
+ if DEBUG:
645
+ import logging
646
+ logging.getLogger("voicemode").debug(f"Could not initialize soundfonts: {e}")
558
647
 
559
648
  # ==================== UTILITY FUNCTIONS ====================
560
649
 
@@ -841,3 +930,28 @@ def get_format_export_params(format: str) -> dict:
841
930
  pass
842
931
 
843
932
  return params
933
+
934
+ # ==================== THINK OUT LOUD CONFIGURATION ====================
935
+
936
+ # Enable Think Out Loud mode
937
+ THINK_OUT_LOUD_ENABLED = env_bool("VOICEMODE_THINK_OUT_LOUD", False)
938
+
939
+ # Voice persona mappings for thinking roles (role:voice pairs)
940
+ # Default: analytical:am_adam,creative:af_sarah,critical:af_bella,synthesis:af_nova
941
+ THINKING_VOICES_STR = os.getenv(
942
+ "VOICEMODE_THINKING_VOICES",
943
+ "analytical:am_adam,creative:af_sarah,critical:af_bella,synthesis:af_nova"
944
+ )
945
+
946
+ # Parse thinking voices into a dictionary
947
+ THINKING_VOICES = {}
948
+ for pair in THINKING_VOICES_STR.split(","):
949
+ if ":" in pair:
950
+ role, voice = pair.strip().split(":", 1)
951
+ THINKING_VOICES[role.strip()] = voice.strip()
952
+
953
+ # Thinking presentation style: sequential, debate, or chorus
954
+ THINKING_STYLE = os.getenv("VOICEMODE_THINKING_STYLE", "sequential")
955
+
956
+ # Whether to announce which voice is speaking
957
+ THINKING_ANNOUNCE_VOICE = env_bool("VOICEMODE_THINKING_ANNOUNCE_VOICE", True)
@@ -1 +1 @@
1
- pbDjheefW1LwCua_8mPoZ
1
+ uZHYPdRYOObTZ3MQHekp0