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.
- voice_mode/__version__.py +1 -1
- voice_mode/cli.py +91 -3
- voice_mode/cli_commands/claude.py +208 -0
- voice_mode/cli_commands/hook.py +209 -0
- voice_mode/config.py +114 -0
- voice_mode/frontend/.next/BUILD_ID +1 -1
- voice_mode/frontend/.next/app-build-manifest.json +5 -5
- voice_mode/frontend/.next/build-manifest.json +3 -3
- voice_mode/frontend/.next/next-minimal-server.js.nft.json +1 -1
- voice_mode/frontend/.next/next-server.js.nft.json +1 -1
- voice_mode/frontend/.next/prerender-manifest.json +1 -1
- voice_mode/frontend/.next/required-server-files.json +1 -1
- voice_mode/frontend/.next/server/app/_not-found/page.js +1 -1
- voice_mode/frontend/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- voice_mode/frontend/.next/server/app/_not-found.html +1 -1
- voice_mode/frontend/.next/server/app/_not-found.rsc +1 -1
- voice_mode/frontend/.next/server/app/api/connection-details/route.js +2 -2
- voice_mode/frontend/.next/server/app/favicon.ico/route.js +2 -2
- voice_mode/frontend/.next/server/app/index.html +1 -1
- voice_mode/frontend/.next/server/app/index.rsc +2 -2
- voice_mode/frontend/.next/server/app/page.js +3 -3
- voice_mode/frontend/.next/server/app/page_client-reference-manifest.js +1 -1
- voice_mode/frontend/.next/server/chunks/994.js +2 -2
- voice_mode/frontend/.next/server/middleware-build-manifest.js +1 -1
- voice_mode/frontend/.next/server/next-font-manifest.js +1 -1
- voice_mode/frontend/.next/server/next-font-manifest.json +1 -1
- voice_mode/frontend/.next/server/pages/404.html +1 -1
- voice_mode/frontend/.next/server/pages/500.html +1 -1
- voice_mode/frontend/.next/server/pages-manifest.json +1 -1
- voice_mode/frontend/.next/server/server-reference-manifest.json +1 -1
- voice_mode/frontend/.next/standalone/.next/BUILD_ID +1 -1
- voice_mode/frontend/.next/standalone/.next/app-build-manifest.json +5 -5
- voice_mode/frontend/.next/standalone/.next/build-manifest.json +3 -3
- voice_mode/frontend/.next/standalone/.next/prerender-manifest.json +1 -1
- voice_mode/frontend/.next/standalone/.next/required-server-files.json +1 -1
- voice_mode/frontend/.next/standalone/.next/server/app/_not-found/page.js +1 -1
- voice_mode/frontend/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- voice_mode/frontend/.next/standalone/.next/server/app/_not-found.html +1 -1
- voice_mode/frontend/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- voice_mode/frontend/.next/standalone/.next/server/app/api/connection-details/route.js +2 -2
- voice_mode/frontend/.next/standalone/.next/server/app/favicon.ico/route.js +2 -2
- voice_mode/frontend/.next/standalone/.next/server/app/index.html +1 -1
- voice_mode/frontend/.next/standalone/.next/server/app/index.rsc +2 -2
- voice_mode/frontend/.next/standalone/.next/server/app/page.js +3 -3
- voice_mode/frontend/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- voice_mode/frontend/.next/standalone/.next/server/chunks/994.js +2 -2
- voice_mode/frontend/.next/standalone/.next/server/middleware-build-manifest.js +1 -1
- voice_mode/frontend/.next/standalone/.next/server/next-font-manifest.js +1 -1
- voice_mode/frontend/.next/standalone/.next/server/next-font-manifest.json +1 -1
- voice_mode/frontend/.next/standalone/.next/server/pages/404.html +1 -1
- voice_mode/frontend/.next/standalone/.next/server/pages/500.html +1 -1
- voice_mode/frontend/.next/standalone/.next/server/pages-manifest.json +1 -1
- voice_mode/frontend/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- voice_mode/frontend/.next/standalone/server.js +1 -1
- voice_mode/frontend/.next/static/chunks/app/{layout-fcb9b9ba5b72c7fc.js → layout-b52135f331b36caf.js} +1 -1
- voice_mode/frontend/.next/static/chunks/app/{page-7c7ec2ad413ace39.js → page-43b7b45e01f0aa87.js} +1 -1
- voice_mode/frontend/.next/static/chunks/{main-app-d02bd38ac01adb8a.js → main-app-80dc9078926fc708.js} +1 -1
- voice_mode/frontend/.next/trace +43 -43
- voice_mode/frontend/.next/types/app/api/connection-details/route.ts +1 -1
- voice_mode/frontend/.next/types/app/layout.ts +1 -1
- voice_mode/frontend/.next/types/app/page.ts +1 -1
- voice_mode/frontend/package-lock.json +3 -3
- voice_mode/tools/claude_thinking.py +285 -0
- voice_mode/tools/sound_fonts/__init__.py +1 -0
- voice_mode/tools/sound_fonts/audio_player.py +87 -0
- voice_mode/tools/sound_fonts/hook_handler.py +127 -0
- voice_mode/tools/sound_fonts/player.py +180 -0
- {voice_mode-4.1.0.dist-info → voice_mode-4.3.1.dist-info}/METADATA +1 -1
- {voice_mode-4.1.0.dist-info → voice_mode-4.3.1.dist-info}/RECORD +73 -66
- /voice_mode/frontend/.next/static/{pbDjheefW1LwCua_8mPoZ → uZHYPdRYOObTZ3MQHekp0}/_buildManifest.js +0 -0
- /voice_mode/frontend/.next/static/{pbDjheefW1LwCua_8mPoZ → uZHYPdRYOObTZ3MQHekp0}/_ssgManifest.js +0 -0
- {voice_mode-4.1.0.dist-info → voice_mode-4.3.1.dist-info}/WHEEL +0 -0
- {voice_mode-4.1.0.dist-info → voice_mode-4.3.1.dist-info}/entry_points.txt +0 -0
voice_mode/__version__.py
CHANGED
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(
|
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
|
-
|
1
|
+
uZHYPdRYOObTZ3MQHekp0
|