agentvibes 5.4.0 → 5.5.0

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.
@@ -50,3 +50,15 @@ _party_mode|compand 0.3,1 6:-70,-60,-20|agent_vibes_dark_chill_step_loop.mp3|0.4
50
50
  |||
51
51
  # Default (no agent specified) - clean with Bachata background|||
52
52
  default|reverb 20 50 50|agentvibes_soft_flamenco_loop.mp3|0.30
53
+ |||
54
+ # Per-LLM routing rows — looked up by play-tts.sh / play-tts.ps1 when -llm is passed|||
55
+ # Format: llm:<name>|REVERB_PRESET|BACKGROUND_FILE|BACKGROUND_VOLUME|VOICE|PRETEXT|ENGINE|||
56
+ |||
57
+ # Claude Code (claude-code CLI via CLAUDECODE=1 env or AGENTVIBES_LLM=claude-code)|||
58
+ llm:claude-code|off||||Agent Vibes Here|piper
59
+ |||
60
+ # GitHub Copilot Chat (VS Code MCP or AGENTVIBES_LLM=copilot)|||
61
+ llm:copilot|off||||Agent Vibes Here|piper
62
+ |||
63
+ # OpenAI Codex CLI (AGENTVIBES_LLM=codex)|||
64
+ llm:codex|off||||Agent Vibes Here|piper
@@ -24,4 +24,4 @@ agent_vibes_arabic_v2_loop.mp3:.00000000000000000006132724
24
24
  agent_vibes_chillwave_v2_loop.mp3:14.628390
25
25
  agent_vibes_bachata_v1_loop.mp3:.00000000000000000005344000
26
26
  agent_vibes_goa_trance_v2_loop.mp3:.00000000000000000002499918
27
- agentvibes_soft_flamenco_loop.mp3:10.736417
27
+ agentvibes_soft_flamenco_loop.mp3:10.584014
@@ -1 +1 @@
1
- 20260422
1
+ 20260427
File without changes
@@ -50,11 +50,15 @@ if [[ ! -f "$PROJECT_ROOT/_bmad/_config/agent-manifest.csv" ]]; then
50
50
  fi
51
51
 
52
52
  # ---------------------------------------------------------------------------
53
- # Per-agent profile reader — reads from ~/.agentvibes/bmad-voice-map.json
53
+ # Per-agent profile reader — reads from project .agentvibes/bmad-voice-map.json (falls back to global)
54
54
  # Uses node for reliable JSON parsing (jq may not be installed)
55
55
  # Returns empty string if field not found or file missing
56
56
 
57
- VOICE_MAP_FILE="$HOME/.agentvibes/bmad-voice-map.json"
57
+ if [[ -f "$PROJECT_ROOT/.agentvibes/bmad-voice-map.json" ]]; then
58
+ VOICE_MAP_FILE="$PROJECT_ROOT/.agentvibes/bmad-voice-map.json"
59
+ else
60
+ VOICE_MAP_FILE="$HOME/.agentvibes/bmad-voice-map.json"
61
+ fi
58
62
 
59
63
  # Read a field from the per-agent profile in bmad-voice-map.json
60
64
  # Usage: read_agent_profile <agent_id> <field>
@@ -551,8 +551,8 @@ if [[ "${AGENTVIBES_TEST_MODE:-false}" != "true" ]] && [[ "${AGENTVIBES_NO_PLAYB
551
551
  termux-media-player play "$TEMP_FILE" >/dev/null 2>&1 &
552
552
  PLAYER_PID=$!
553
553
  else
554
- # Linux/WSL: Prefer paplay (PulseAudio) for best WSL audio quality
555
- (paplay "$TEMP_FILE" || mpv "$TEMP_FILE" || aplay "$TEMP_FILE") >/dev/null 2>&1 &
554
+ # Linux/WSL: paplay with 500ms latency buffer prevents choppiness over RDP/network audio
555
+ (paplay --latency-msec=500 "$TEMP_FILE" || mpv "$TEMP_FILE" || aplay -B 2000000 "$TEMP_FILE") >/dev/null 2>&1 &
556
556
  PLAYER_PID=$!
557
557
  fi
558
558
  fi
@@ -103,8 +103,29 @@ elif [[ -f "$GLOBAL_MUTE_FILE" ]]; then
103
103
  exit 0
104
104
  fi
105
105
 
106
+ # Parse named flags (e.g. --llm) before positional arguments.
107
+ # This allows callers to pass: play-tts.sh --llm claude-code "text to speak"
108
+ # Named args are extracted; remaining positional args are shifted into $1/$2/$3.
109
+ LLM_PROVIDER="${LLM_PROVIDER:-}"
110
+ _POSITIONAL_ARGS=()
111
+ while [[ $# -gt 0 ]]; do
112
+ case "$1" in
113
+ --llm)
114
+ LLM_PROVIDER="${2:-}"
115
+ shift 2
116
+ ;;
117
+ *)
118
+ _POSITIONAL_ARGS+=("$1")
119
+ shift
120
+ ;;
121
+ esac
122
+ done
123
+ set -- "${_POSITIONAL_ARGS[@]+"${_POSITIONAL_ARGS[@]}"}"
124
+ unset _POSITIONAL_ARGS
125
+
106
126
  TEXT="${1:-}"
107
127
  VOICE_OVERRIDE="${2:-}" # Optional: voice name or ID
128
+ AGENT_PROFILE_FILE="${3:-}" # Optional: path to agent profile file
108
129
 
109
130
  # Security: Validate inputs
110
131
  if [[ -z "$TEXT" ]]; then
@@ -397,7 +418,7 @@ fi
397
418
  # Normal single-language mode - route to appropriate provider implementation
398
419
  case "$ACTIVE_PROVIDER" in
399
420
  piper)
400
- exec bash "$SCRIPT_DIR/play-tts-piper.sh" "$TEXT" "$VOICE_OVERRIDE" "$AGENT_PROFILE_FILE"
421
+ exec bash "$SCRIPT_DIR/play-tts-piper.sh" "$TEXT" "$VOICE_OVERRIDE" "${AGENT_PROFILE_FILE:-}"
401
422
  ;;
402
423
  soprano)
403
424
  exec bash "$SCRIPT_DIR/play-tts-soprano.sh" "$TEXT" "$VOICE_OVERRIDE"
@@ -1,164 +1,178 @@
1
- #
2
- # File: .claude/hooks-windows/play-tts-windows-piper.ps1
3
- #
4
- # AgentVibes - Windows Piper TTS Provider
5
- # High-quality neural TTS using Piper.exe
6
- #
7
-
8
- param(
9
- [Parameter(Mandatory = $true)]
10
- [string]$Text,
11
-
12
- [Parameter(Mandatory = $false)]
13
- [string]$VoiceOverride
14
- )
15
-
16
- # Configuration paths
17
- $ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
18
- $ProjectClaudeDir = Join-Path (Split-Path -Parent (Split-Path -Parent $ScriptPath)) ".claude"
19
-
20
- if (Test-Path $ProjectClaudeDir) {
21
- $ClaudeDir = $ProjectClaudeDir
22
- } else {
23
- $ClaudeDir = "$env:USERPROFILE\.claude"
24
- }
25
-
26
- # Audio cache and voice config use project-local .claude
27
- $AudioDir = "$ClaudeDir\audio"
28
- $VoiceFile = "$ClaudeDir\tts-voice-piper.txt"
29
-
30
- # Voices and Piper binary are global (shared across projects, ~100MB+)
31
- $UserClaudeDir = "$env:USERPROFILE\.claude"
32
- $VoicesDir = "$UserClaudeDir\piper-voices"
33
- $PiperExe = "$env:LOCALAPPDATA\Programs\Piper\piper.exe"
34
-
35
- # Ensure directories exist
36
- foreach ($dir in @($AudioDir, $VoicesDir)) {
37
- if (-not (Test-Path $dir)) {
38
- New-Item -ItemType Directory -Path $dir -Force | Out-Null
39
- }
40
- }
41
-
42
- # Check if Piper is installed
43
- if (-not (Test-Path $PiperExe)) {
44
- Write-Host "[ERROR] Piper not found at: $PiperExe" -ForegroundColor Red
45
- Write-Host "Run: .\setup-windows.ps1 to install Piper" -ForegroundColor Yellow
46
- exit 1
47
- }
48
-
49
- # Determine voice to use
50
- $VoiceName = ""
51
-
52
- if ($VoiceOverride) {
53
- $VoiceName = $VoiceOverride
54
- }
55
- elseif (Test-Path $VoiceFile) {
56
- $VoiceName = (Get-Content $VoiceFile -Raw).Trim()
57
- }
58
-
59
- # Default voice if not specified
60
- if (-not $VoiceName) {
61
- $VoiceName = "en_US-ryan-high"
62
- }
63
-
64
- # Security: Validate voice name to prevent path traversal
65
- # Only allow alphanumeric, underscore, hyphen, and period
66
- if ($VoiceName -notmatch '^[a-zA-Z0-9_\-\.]+$') {
67
- Write-Host "[ERROR] Invalid voice name: $VoiceName" -ForegroundColor Red
68
- exit 1
69
- }
70
-
71
- # Resolve voice model path and validate it stays within VoicesDir
72
- $VoiceModelFile = [System.IO.Path]::GetFullPath("$VoicesDir\$VoiceName.onnx")
73
- $VoiceJsonFile = [System.IO.Path]::GetFullPath("$VoicesDir\$VoiceName.onnx.json")
74
- $ResolvedVoicesDir = [System.IO.Path]::GetFullPath($VoicesDir)
75
- if (-not $VoiceModelFile.StartsWith($ResolvedVoicesDir)) {
76
- Write-Host "[ERROR] Voice path outside voices directory" -ForegroundColor Red
77
- exit 1
78
- }
79
-
80
- # Check if voice model exists, download if missing
81
- if (-not (Test-Path $VoiceModelFile)) {
82
- Write-Host "[DOWNLOAD] Voice model: $VoiceName" -ForegroundColor Yellow
83
-
84
- # Try to download from Hugging Face
85
- # Voice name format: {lang}_{region}-{speaker}-{quality}
86
- # HF path format: {lang}/{lang}_{region}/{speaker}/{quality}/{voicename}.onnx
87
- try {
88
- # Parse voice name to build correct HF path
89
- # e.g. en_US-ryan-high -> en/en_US/ryan/high/en_US-ryan-high.onnx
90
- if ($VoiceName -match '^([a-z]{2})_([A-Z]{2})-([a-zA-Z0-9_]+)-([a-z]+)$') {
91
- $Lang = $Matches[1]
92
- $LangRegion = "$($Matches[1])_$($Matches[2])"
93
- $Speaker = $Matches[3]
94
- $Quality = $Matches[4]
95
- $HFBase = "https://huggingface.co/rhasspy/piper-voices/resolve/main/$Lang/$LangRegion/$Speaker/$Quality"
96
- } else {
97
- # Fallback for non-standard voice names
98
- $HFBase = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ryan/high"
99
- }
100
- $ModelUrl = "$HFBase/$VoiceName.onnx"
101
- $JsonUrl = "$HFBase/$VoiceName.onnx.json"
102
-
103
- Write-Host " Downloading model..." -ForegroundColor Cyan
104
- Invoke-WebRequest -Uri $ModelUrl -OutFile $VoiceModelFile -ErrorAction Stop
105
- Write-Host " Downloading config..." -ForegroundColor Cyan
106
- Invoke-WebRequest -Uri $JsonUrl -OutFile $VoiceJsonFile -ErrorAction Stop
107
- Write-Host "[OK] Voice model downloaded" -ForegroundColor Green
108
- }
109
- catch {
110
- Write-Host "[ERROR] Failed to download voice model: $_" -ForegroundColor Red
111
- Write-Host "Make sure you have internet connection" -ForegroundColor Yellow
112
- exit 1
113
- }
114
- }
115
-
116
- # Sanitize text for speech - strip shell metacharacters and PS special chars
117
- $Text = $Text -replace '\\', ' '
118
- $Text = $Text -replace '[{}<>|`~^$;"''()]', ''
119
- $Text = $Text -replace '\s+', ' '
120
- $Text = $Text.Trim()
121
-
122
- # Create audio file path
123
- $Timestamp = Get-Date -Format 'yyyyMMdd-HHmmss-ffff'
124
- $AudioFile = "$AudioDir\tts-$Timestamp.wav"
125
-
126
- # Synthesize with Piper
127
- try {
128
- Write-Host "[SYNTH] Synthesizing with Piper..." -ForegroundColor Cyan
129
-
130
- # Run Piper with text input
131
- $Text | & $PiperExe `
132
- --model $VoiceModelFile `
133
- --output-file $AudioFile `
134
- 2>$null
135
-
136
- if (-not (Test-Path $AudioFile)) {
137
- Write-Host "[ERROR] Piper synthesis failed" -ForegroundColor Red
138
- exit 1
139
- }
140
-
141
- # Display results
142
- Write-Host "[OK] Saved to: $AudioFile" -ForegroundColor Green
143
- Write-Host "[VOICE] Voice used: $VoiceName (Piper)" -ForegroundColor Green
144
-
145
- # Play the audio using built-in Windows audio player (skip if AGENTVIBES_NO_PLAY is set)
146
- if (-not $env:AGENTVIBES_NO_PLAY) {
147
- $player = $null
148
- try {
149
- $player = New-Object System.Media.SoundPlayer $AudioFile
150
- $player.PlaySync()
151
- }
152
- catch {
153
- Write-Host "[WARNING] Could not play audio (SoundPlayer unavailable)" -ForegroundColor Yellow
154
- Write-Host "Audio saved to: $AudioFile" -ForegroundColor Gray
155
- }
156
- finally {
157
- if ($player) { $player.Dispose() }
158
- }
159
- }
160
- }
161
- catch {
162
- Write-Host "[ERROR] Error running Piper: $_" -ForegroundColor Red
163
- exit 1
164
- }
1
+ #
2
+ # File: .claude/hooks-windows/play-tts-windows-piper.ps1
3
+ #
4
+ # AgentVibes - Windows Piper TTS Provider
5
+ # High-quality neural TTS using Piper.exe
6
+ #
7
+
8
+ param(
9
+ [Parameter(Mandatory = $true)]
10
+ [string]$Text,
11
+
12
+ [Parameter(Mandatory = $false)]
13
+ [string]$VoiceOverride
14
+ )
15
+
16
+ # Configuration paths
17
+ $ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
18
+ $ProjectClaudeDir = Join-Path (Split-Path -Parent (Split-Path -Parent $ScriptPath)) ".claude"
19
+
20
+ if (Test-Path $ProjectClaudeDir) {
21
+ $ClaudeDir = $ProjectClaudeDir
22
+ } else {
23
+ $ClaudeDir = "$env:USERPROFILE\.claude"
24
+ }
25
+
26
+ # Audio cache and voice config use project-local .claude
27
+ $AudioDir = "$ClaudeDir\audio"
28
+ $VoiceFile = "$ClaudeDir\tts-voice-piper.txt"
29
+
30
+ # Voices and Piper binary are global (shared across projects, ~100MB+)
31
+ $UserClaudeDir = "$env:USERPROFILE\.claude"
32
+ $VoicesDir = "$UserClaudeDir\piper-voices"
33
+ $PiperExe = "$env:LOCALAPPDATA\Programs\Piper\piper.exe"
34
+
35
+ # Ensure directories exist
36
+ foreach ($dir in @($AudioDir, $VoicesDir)) {
37
+ if (-not (Test-Path $dir)) {
38
+ New-Item -ItemType Directory -Path $dir -Force | Out-Null
39
+ }
40
+ }
41
+
42
+ # Check if Piper is installed
43
+ if (-not (Test-Path $PiperExe)) {
44
+ Write-Host "[ERROR] Piper not found at: $PiperExe" -ForegroundColor Red
45
+ Write-Host "Run: .\setup-windows.ps1 to install Piper" -ForegroundColor Yellow
46
+ exit 1
47
+ }
48
+
49
+ # Determine voice to use
50
+ $VoiceName = ""
51
+
52
+ if ($VoiceOverride) {
53
+ $VoiceName = $VoiceOverride
54
+ }
55
+ elseif (Test-Path $VoiceFile) {
56
+ $VoiceName = (Get-Content $VoiceFile -Raw).Trim()
57
+ }
58
+
59
+ # Default voice if not specified
60
+ if (-not $VoiceName) {
61
+ $VoiceName = "en_US-ryan-high"
62
+ }
63
+
64
+ # Security: Validate voice name to prevent path traversal
65
+ # Only allow alphanumeric, underscore, hyphen, and period
66
+ if ($VoiceName -notmatch '^[a-zA-Z0-9_\-\.]+$') {
67
+ Write-Host "[ERROR] Invalid voice name: $VoiceName" -ForegroundColor Red
68
+ exit 1
69
+ }
70
+
71
+ # Resolve voice model path and validate it stays within VoicesDir
72
+ $VoiceModelFile = [System.IO.Path]::GetFullPath("$VoicesDir\$VoiceName.onnx")
73
+ $VoiceJsonFile = [System.IO.Path]::GetFullPath("$VoicesDir\$VoiceName.onnx.json")
74
+ $ResolvedVoicesDir = [System.IO.Path]::GetFullPath($VoicesDir)
75
+ if (-not $VoiceModelFile.StartsWith($ResolvedVoicesDir)) {
76
+ Write-Host "[ERROR] Voice path outside voices directory" -ForegroundColor Red
77
+ exit 1
78
+ }
79
+
80
+ # Check if voice model exists, download if missing
81
+ if (-not (Test-Path $VoiceModelFile)) {
82
+ Write-Host "[DOWNLOAD] Voice model: $VoiceName" -ForegroundColor Yellow
83
+
84
+ # Try to download from Hugging Face
85
+ # Voice name format: {lang}_{region}-{speaker}-{quality}
86
+ # HF path format: {lang}/{lang}_{region}/{speaker}/{quality}/{voicename}.onnx
87
+ try {
88
+ # Parse voice name to build correct HF path
89
+ # e.g. en_US-ryan-high -> en/en_US/ryan/high/en_US-ryan-high.onnx
90
+ if ($VoiceName -match '^([a-z]{2})_([A-Z]{2})-([a-zA-Z0-9_]+)-([a-z]+)$') {
91
+ $Lang = $Matches[1]
92
+ $LangRegion = "$($Matches[1])_$($Matches[2])"
93
+ $Speaker = $Matches[3]
94
+ $Quality = $Matches[4]
95
+ $HFBase = "https://huggingface.co/rhasspy/piper-voices/resolve/main/$Lang/$LangRegion/$Speaker/$Quality"
96
+ } else {
97
+ # Fallback for non-standard voice names
98
+ $HFBase = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ryan/high"
99
+ }
100
+ $ModelUrl = "$HFBase/$VoiceName.onnx"
101
+ $JsonUrl = "$HFBase/$VoiceName.onnx.json"
102
+
103
+ Write-Host " Downloading model..." -ForegroundColor Cyan
104
+ Invoke-WebRequest -Uri $ModelUrl -OutFile $VoiceModelFile -ErrorAction Stop
105
+ Write-Host " Downloading config..." -ForegroundColor Cyan
106
+ Invoke-WebRequest -Uri $JsonUrl -OutFile $VoiceJsonFile -ErrorAction Stop
107
+ Write-Host "[OK] Voice model downloaded" -ForegroundColor Green
108
+ }
109
+ catch {
110
+ Write-Host "[ERROR] Failed to download voice model: $_" -ForegroundColor Red
111
+ Write-Host "Make sure you have internet connection" -ForegroundColor Yellow
112
+ exit 1
113
+ }
114
+ }
115
+
116
+ # Sanitize text for speech - strip shell metacharacters and PS special chars
117
+ $Text = $Text -replace '\\', ' '
118
+ $Text = $Text -replace '[{}<>|`~^$;"''()]', ''
119
+ $Text = $Text -replace '\s+', ' '
120
+ $Text = $Text.Trim()
121
+
122
+ # Create audio file path
123
+ $Timestamp = Get-Date -Format 'yyyyMMdd-HHmmss-ffff'
124
+ $AudioFile = "$AudioDir\tts-$Timestamp.wav"
125
+
126
+ # Synthesize with Piper
127
+ try {
128
+ Write-Host "[SYNTH] Synthesizing with Piper..." -ForegroundColor Cyan
129
+
130
+ # Run Piper with text input
131
+ $Text | & $PiperExe `
132
+ --model $VoiceModelFile `
133
+ --output-file $AudioFile `
134
+ 2>$null
135
+
136
+ if (-not (Test-Path $AudioFile)) {
137
+ Write-Host "[ERROR] Piper synthesis failed" -ForegroundColor Red
138
+ exit 1
139
+ }
140
+
141
+ # Display results
142
+ Write-Host "[OK] Saved to: $AudioFile" -ForegroundColor Green
143
+ Write-Host "[VOICE] Voice used: $VoiceName (Piper)" -ForegroundColor Green
144
+
145
+ # Play the audio (skip if AGENTVIBES_NO_PLAY is set)
146
+ if (-not $env:AGENTVIBES_NO_PLAY) {
147
+ # Prefer ffplay: handles 22050 Hz → 48000 Hz resampling cleanly (SoundPlayer uses
148
+ # WinMM's low-quality resampler which produces choppy audio at non-native rates).
149
+ $ffplayPath = (Get-Command ffplay -ErrorAction SilentlyContinue)?.Source
150
+ if (-not $ffplayPath) {
151
+ # SSH/watcher sessions may have a minimal PATH — refresh from registry
152
+ $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" +
153
+ [System.Environment]::GetEnvironmentVariable("Path","User")
154
+ $ffplayPath = (Get-Command ffplay -ErrorAction SilentlyContinue)?.Source
155
+ }
156
+ if ($ffplayPath) {
157
+ & $ffplayPath -autoexit -nodisp -loglevel quiet $AudioFile 2>$null
158
+ }
159
+ else {
160
+ $player = $null
161
+ try {
162
+ $player = New-Object System.Media.SoundPlayer $AudioFile
163
+ $player.PlaySync()
164
+ }
165
+ catch {
166
+ Write-Host "[WARNING] Could not play audio (SoundPlayer unavailable)" -ForegroundColor Yellow
167
+ Write-Host "Audio saved to: $AudioFile" -ForegroundColor Gray
168
+ }
169
+ finally {
170
+ if ($player) { $player.Dispose() }
171
+ }
172
+ }
173
+ }
174
+ }
175
+ catch {
176
+ Write-Host "[ERROR] Error running Piper: $_" -ForegroundColor Red
177
+ exit 1
178
+ }
@@ -10,7 +10,13 @@ param(
10
10
  [string]$Text,
11
11
 
12
12
  [Parameter(Mandatory = $false, Position = 1)]
13
- [string]$VoiceOverride
13
+ [string]$VoiceOverride,
14
+
15
+ # LLM identity for per-LLM audio routing (e.g. "claude-code", "copilot", "codex").
16
+ # When provided, the router looks up an `llm:<name>` row in audio-effects.cfg
17
+ # to apply LLM-specific voice, pretext, reverb, and engine settings.
18
+ [Parameter(Mandatory = $false)]
19
+ [string]$llm = ""
14
20
  )
15
21
 
16
22
  # Configuration paths
@@ -97,6 +103,201 @@ if ($BgEnabled -or $HasReverb) {
97
103
  } catch {}
98
104
  }
99
105
 
106
+ # ===========================================================================
107
+ # Per-LLM Audio Routing
108
+ # ===========================================================================
109
+ # When mcp-server/server.py invokes play-tts.ps1 on Windows it passes the
110
+ # -llm flag with the active LLM identity (e.g. "claude-code", "copilot",
111
+ # "codex"). The router reads audio-effects.cfg and looks up the row whose
112
+ # key is `llm:<name>`, allowing each LLM to have its own voice, pretext,
113
+ # reverb, and engine without requiring global settings to be reconfigured.
114
+ #
115
+ # Expected audio-effects.cfg row format (pipe-delimited):
116
+ # llm:<name>|REVERB_PRESET|BACKGROUND_FILE|BACKGROUND_VOLUME|VOICE|PRETEXT|ENGINE
117
+ #
118
+ # Column descriptions:
119
+ # 1. Key - Must start with "llm:" followed by the LLM name
120
+ # 2. REVERB_PRESET - One of: off, light, medium, heavy, cathedral (or blank)
121
+ # 3. BACKGROUND_FILE - Filename relative to .claude/audio/tracks/ (or blank)
122
+ # 4. BACKGROUND_VOLUME - Float 0.0-1.0 (or blank for default 0.25)
123
+ # 5. VOICE - Provider voice name to use (or blank for global default)
124
+ # 6. PRETEXT - Text prepended to all TTS utterances (or blank)
125
+ # 7. ENGINE - Windows engine: windows-sapi, windows-piper, soprano (or blank)
126
+ #
127
+ # Example rows:
128
+ # llm:claude-code|off|||en_US-amy-medium|Agent Vibes Here|windows-piper
129
+ # llm:copilot|light|||en_US-ryan-low||windows-sapi
130
+ # llm:codex|off||||Code complete|windows-piper
131
+ # llm:default|off|||||
132
+ #
133
+ # The "default" key is always looked up when no explicit -llm flag is
134
+ # provided. Configure it via Setup → Default → Configure in the TUI to
135
+ # apply consistent audio settings across all LLM sessions.
136
+ #
137
+ # Security: The -llm value is validated against an allowlist regex so that
138
+ # injected values like "-rf" or path-traversal strings are rejected before
139
+ # they can appear in lookup keys, environment variables, or file paths.
140
+
141
+ # --- Validate -llm parameter format ------------------------------------------
142
+ if ($llm -and $llm -notmatch '^[a-zA-Z0-9][a-zA-Z0-9_-]*$') {
143
+ Write-Host "[WARNING] play-tts.ps1: Invalid -llm value '$llm' ignored" `
144
+ "(must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*`$)" -ForegroundColor Yellow
145
+ $llm = ""
146
+ }
147
+
148
+ # --- Default fallback --------------------------------------------------------
149
+ # An empty $llm routes through the "default" pseudo-LLM. Users who configure
150
+ # an `llm:default` row in audio-effects.cfg get consistent audio settings for
151
+ # every LLM that doesn't pass its own -llm flag — a convenient global override
152
+ # that doesn't require per-LLM configuration.
153
+ if (-not $llm) {
154
+ $llm = "default"
155
+ }
156
+
157
+ # --- Export LLM key for child scripts ----------------------------------------
158
+ # Provider scripts (play-tts-windows-*.ps1) and any other downstream tooling
159
+ # can inspect AGENTVIBES_LLM_KEY to identify which LLM is currently speaking.
160
+ # This mirrors the `export AGENTVIBES_LLM_KEY="llm:${LLM_PROVIDER}"` line in
161
+ # the POSIX play-tts.sh so the cross-platform contract is symmetric.
162
+ $env:AGENTVIBES_LLM_KEY = "llm:$llm"
163
+
164
+ # --- Lookup per-LLM config in audio-effects.cfg ------------------------------
165
+ # Scan project config first, then user-profile config. Stop at first match.
166
+ # Variables are intentionally prefixed with _ to distinguish LLM-local state
167
+ # from the global session state set earlier in this script.
168
+ $_LlmVoice = ""
169
+ $_LlmPretext = ""
170
+ $_LlmReverb = ""
171
+ $_LlmEngine = ""
172
+ $_LlmBgFile = ""
173
+ $_LlmBgVol = ""
174
+ $_LlmKey = "llm:$llm"
175
+
176
+ $_AudioEffectsCfgPaths = @(
177
+ (Join-Path $ClaudeDir "config\audio-effects.cfg"),
178
+ (Join-Path $env:USERPROFILE ".claude\config\audio-effects.cfg")
179
+ )
180
+
181
+ :llmCfgSearch foreach ($_cfgFile in $_AudioEffectsCfgPaths) {
182
+ if ((-not $_LlmVoice) -and (-not $_LlmPretext) -and (Test-Path $_cfgFile)) {
183
+ $cfgContent = Get-Content $_cfgFile -ErrorAction SilentlyContinue
184
+ if ($null -ne $cfgContent) {
185
+ foreach ($_cfgLine in $cfgContent) {
186
+ # Skip blank lines and comment / separator lines
187
+ $stripped = $_cfgLine.Trim()
188
+ if ($stripped.Length -eq 0 -or $stripped.StartsWith('#')) { continue }
189
+
190
+ # Split on pipe; expect at least the key column
191
+ $_cols = $_cfgLine -split '\|'
192
+ if ($_cols.Count -ge 1 -and $_cols[0].Trim() -eq $_LlmKey) {
193
+ # Unpack columns defensively — missing columns stay empty
194
+ if ($_cols.Count -ge 2) { $_LlmReverb = $_cols[1].Trim() }
195
+ if ($_cols.Count -ge 3) { $_LlmBgFile = $_cols[2].Trim() }
196
+ if ($_cols.Count -ge 4) { $_LlmBgVol = $_cols[3].Trim() }
197
+ if ($_cols.Count -ge 5) { $_LlmVoice = $_cols[4].Trim() }
198
+ if ($_cols.Count -ge 6) { $_LlmPretext = $_cols[5].Trim() }
199
+ if ($_cols.Count -ge 7) { $_LlmEngine = $_cols[6].Trim() }
200
+ break llmCfgSearch
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ # --- Voice priority order (highest wins) -------------------------------------
208
+ # 1. Explicit -VoiceOverride parameter (caller always wins)
209
+ # 2. LLM-specific voice from audio-effects.cfg llm:<key> row
210
+ # 3. BMAD agent voice from bmad-voice-map.json (resolved in provider scripts)
211
+ # 4. Global active voice from tts-provider.txt / active-voice.txt
212
+
213
+ # Apply LLM-specific voice only when no explicit -VoiceOverride was passed
214
+ if ($_LlmVoice -and -not $VoiceOverride) {
215
+ $VoiceOverride = $_LlmVoice
216
+ }
217
+
218
+ # --- Apply LLM-specific pretext ----------------------------------------------
219
+ # Prepend the configured pretext (e.g. "Agent Vibes Here") to the speech
220
+ # text. Guard against double-prefixing on re-entrant or looped calls by
221
+ # checking whether the text already starts with the pretext string.
222
+ if ($_LlmPretext -and -not $Text.StartsWith($_LlmPretext)) {
223
+ $Text = "$_LlmPretext, $Text"
224
+ }
225
+
226
+ # --- Reverb override from per-LLM config -------------------------------------
227
+ # If the llm:<key> row specifies a reverb preset, override the file-based
228
+ # $ReverbLevel that was set from reverb-level.txt earlier. The allowlist
229
+ # check is repeated here so a malformed config row can't inject arbitrary
230
+ # strings into the ffmpeg filter chain.
231
+ if ($_LlmReverb) {
232
+ $validReverbLevels = @("off", "light", "medium", "heavy", "cathedral")
233
+ if ($validReverbLevels -contains $_LlmReverb) {
234
+ $ReverbLevel = $_LlmReverb
235
+ $HasReverb = $ReverbLevel -ne "off"
236
+ # If the LLM config enables reverb and ffmpeg wasn't found yet, retry
237
+ if ($HasReverb -and -not $HasFfmpeg) {
238
+ try { $null = Get-Command ffmpeg -ErrorAction Stop; $HasFfmpeg = $true } catch {}
239
+ }
240
+ }
241
+ }
242
+
243
+ # --- Apply LLM-specific engine override --------------------------------------
244
+ # Allowed local Windows engines: windows-sapi, windows-piper, soprano.
245
+ # Transport providers (ssh-remote etc.) are not listed because they forward
246
+ # TTS to a remote host — overriding with a local engine would synthesize on
247
+ # the wrong machine.
248
+ if ($_LlmEngine) {
249
+ $allowedLocalEngines = @("windows-sapi", "windows-piper", "soprano")
250
+ if ($allowedLocalEngines -contains $_LlmEngine) {
251
+ switch ($_LlmEngine) {
252
+ "windows-sapi" { $ProviderScript = "$HooksDir\play-tts-windows-sapi.ps1" }
253
+ "windows-piper" { $ProviderScript = "$HooksDir\play-tts-windows-piper.ps1" }
254
+ "soprano" { $ProviderScript = "$HooksDir\play-tts-soprano.ps1" }
255
+ }
256
+ } else {
257
+ Write-Host "[INFO] play-tts.ps1: Unrecognised engine '$_LlmEngine' in audio-effects.cfg — keeping default provider" -ForegroundColor DarkGray
258
+ }
259
+ }
260
+
261
+ # --- BMAD Party Mode note ----------------------------------------------------
262
+ # When BMAD party mode is active, multiple agents speak in rapid succession.
263
+ # Each agent's voice is resolved from bmad-voice-map.json inside the provider
264
+ # scripts — that BMAD-level routing is independent of this per-LLM system.
265
+ # The -llm flag is still used to set AGENTVIBES_LLM_KEY and can supply a
266
+ # background music track and reverb preset that stays consistent throughout
267
+ # the entire party mode session regardless of which agent is speaking.
268
+
269
+ # --- Diagnostic output -------------------------------------------------------
270
+ # Set AGENTVIBES_VERBOSE=1 in the shell environment to print routing state.
271
+ if ($env:AGENTVIBES_VERBOSE -eq "1") {
272
+ Write-Host "[DEBUG] play-tts.ps1 LLM routing: llm=$llm | voice=$VoiceOverride | engine=$_LlmEngine | pretext=$_LlmPretext" -ForegroundColor DarkCyan
273
+ Write-Host "[DEBUG] play-tts.ps1 LLM routing: reverb=$ReverbLevel | HasFfmpeg=$HasFfmpeg | BgEnabled=$BgEnabled | script=$ProviderScript" -ForegroundColor DarkCyan
274
+ }
275
+
276
+ # ===========================================================================
277
+ # End of Per-LLM Audio Routing
278
+ # ===========================================================================
279
+
280
+ # Helper: play a WAV file preferring ffplay over SoundPlayer.
281
+ # SoundPlayer uses WinMM's low-quality resampler (22050 Hz → 48000 Hz is choppy);
282
+ # ffplay uses libswresample with sinc resampling — no artefacts.
283
+ function Invoke-AudioPlay {
284
+ param([string]$FilePath)
285
+ $fp = (Get-Command ffplay -ErrorAction SilentlyContinue)?.Source
286
+ if (-not $fp) {
287
+ # Watcher sessions may inherit a minimal PATH — refresh from registry
288
+ $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" +
289
+ [System.Environment]::GetEnvironmentVariable("Path","User")
290
+ $fp = (Get-Command ffplay -ErrorAction SilentlyContinue)?.Source
291
+ }
292
+ if ($fp) {
293
+ & $fp -autoexit -nodisp -loglevel quiet $FilePath 2>$null
294
+ } else {
295
+ $p = $null
296
+ try { $p = New-Object System.Media.SoundPlayer $FilePath; $p.PlaySync() }
297
+ finally { if ($p) { $p.Dispose() } }
298
+ }
299
+ }
300
+
100
301
  # If background music or reverb enabled and ffmpeg available, tell provider to skip playback
101
302
  if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
102
303
  $env:AGENTVIBES_NO_PLAY = "1"
@@ -204,61 +405,27 @@ if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
204
405
 
205
406
  if ($proc.ExitCode -eq 0 -and (Test-Path $MixedFile) -and (Get-Item $MixedFile).Length -gt 0) {
206
407
  # Play the mixed audio
207
- $player = $null
208
408
  try {
209
- $player = New-Object System.Media.SoundPlayer $MixedFile
210
- $player.PlaySync()
409
+ Invoke-AudioPlay $MixedFile
211
410
  } catch {
212
411
  Write-Host "[WARNING] Mixed playback failed, playing voice only" -ForegroundColor Yellow
213
- $player2 = $null
214
- try {
215
- $player2 = New-Object System.Media.SoundPlayer $voicePath
216
- $player2.PlaySync()
217
- } finally {
218
- if ($player2) { $player2.Dispose() }
219
- }
220
- } finally {
221
- if ($player) { $player.Dispose() }
412
+ Invoke-AudioPlay $voicePath
222
413
  }
223
414
  } else {
224
415
  # Mixing failed, play voice only
225
- $player = $null
226
- try {
227
- $player = New-Object System.Media.SoundPlayer $voicePath
228
- $player.PlaySync()
229
- } finally {
230
- if ($player) { $player.Dispose() }
231
- }
416
+ Invoke-AudioPlay $voicePath
232
417
  }
233
418
  } catch {
234
419
  # ffmpeg failed, play voice only
235
- $player = $null
236
- try {
237
- $player = New-Object System.Media.SoundPlayer $voicePath
238
- $player.PlaySync()
239
- } finally {
240
- if ($player) { $player.Dispose() }
241
- }
420
+ Invoke-AudioPlay $voicePath
242
421
  }
243
422
  } else {
244
423
  # No background track found, play voice only
245
- $player = $null
246
- try {
247
- $player = New-Object System.Media.SoundPlayer $voicePath
248
- $player.PlaySync()
249
- } finally {
250
- if ($player) { $player.Dispose() }
251
- }
424
+ Invoke-AudioPlay $voicePath
252
425
  }
253
426
  } else {
254
427
  # No background music, play the (possibly reverbed) voice
255
- $player = $null
256
- try {
257
- $player = New-Object System.Media.SoundPlayer $voicePath
258
- $player.PlaySync()
259
- } finally {
260
- if ($player) { $player.Dispose() }
261
- }
428
+ Invoke-AudioPlay $voicePath
262
429
  }
263
430
  }
264
431
  } else {
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Publish](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml/badge.svg)](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml)
12
12
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
13
13
 
14
- **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v5.4.0
14
+ **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v5.5
15
15
 
16
16
  ---
17
17
 
@@ -40,11 +40,21 @@ Whether you're coding in Claude Code, chatting in Claude Desktop, using Warp Ter
40
40
 
41
41
  ---
42
42
 
43
- ## 🌟 NEW FEATURE HIGHLIGHTS
43
+ ## 🌟 NEW IN v5.5 — Per-LLM Audio Routing
44
44
 
45
- ### 🎤 Agent Vibes v1.0 Voice Browser
45
+ Give **each LLM its own voice, pretext, and music** — Claude Code, Copilot, and Codex can all sound different without touching global settings.
46
+
47
+ - Add `llm:<name>|...|voice|pretext|engine` rows to `audio-effects.cfg`
48
+ - MCP server auto-detects which LLM is calling and passes `--llm <key>`
49
+ - Configure via **Setup → Default → Configure** in the TUI
50
+
51
+ Also fixed: Windows installer crash (`spinner.info is not a function`) on **reinstall** with an older global AgentVibes install.
52
+
53
+ ---
46
54
 
47
- ![Voice Browser Banner](docs/installation-screenshots/voice-browser-screenshot.png)
55
+ ## v5.4 — TUI Installer, Spinner Fix & Dependency Cleanup
56
+
57
+ ### 🎤 Agent Vibes v1.0 Voice Browser
48
58
 
49
59
  **🎤 Browse, Sample & Install 914 Voices in Real-Time**
50
60
 
@@ -297,8 +307,6 @@ All 50+ Piper voices AgentVibes provides are sourced from Hugging Face's open-so
297
307
 
298
308
  **Browse and sample 914 voices in real-time!**
299
309
 
300
- ![AgentVibes Voice Browser](docs/installation-screenshots/voice-browser-screenshot.png)
301
-
302
310
  ```bash
303
311
  npx agentvibes-voice-browser
304
312
  ```
@@ -453,9 +461,6 @@ macOS ships with bash 3.2 (from 2007). After this, everything works perfectly!
453
461
 
454
462
  **The easiest way to find your perfect voice!**
455
463
 
456
- ![AgentVibes Voice Browser](docs/installation-screenshots/voice-browser-screenshot.png)
457
- *Browse, sample, and install from 914 voices with real-time audio preview*
458
-
459
464
  ### Launch the Browser
460
465
 
461
466
  ```bash
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # AgentVibes Release Notes
2
2
 
3
+ ## 🎵 v5.5.0 — Per-LLM Audio Routing & Windows Installer Resilience
4
+
5
+ **Released:** 2026-04-27
6
+
7
+ ### 🆕 Per-LLM Audio Routing
8
+ Each LLM (Claude Code, Copilot, Codex) can now have its own voice, pretext, reverb, and
9
+ background-music settings. The MCP server passes `--llm <key>` to both `play-tts.sh`
10
+ (Linux/macOS) and `play-tts.ps1` (Windows), and the scripts look up `llm:<key>` rows in
11
+ `audio-effects.cfg`. Default rows for `claude-code`, `copilot`, and `codex` ship out of the
12
+ box; configure them via **Setup → Default → Configure** in the TUI.
13
+
14
+ ### 🐛 Windows Installer Crash Fix
15
+ Fixed `spinner.info is not a function` error that crashed AgentVibes **reinstalls** on Windows
16
+ when users had an older global install. All 10 file-copy functions in the installer now wrap
17
+ their spinner with `createRobustSpinner()` so stale callers can never cause a crash regardless
18
+ of which methods they expose.
19
+
20
+ ### 🎶 Windows Background Music Parity
21
+ Windows TTS playback now prefers `ffplay` (sinc resampling, no artefacts) over the low-quality
22
+ WinMM `SoundPlayer` resampler. The new `Invoke-AudioPlay` helper handles the fallback
23
+ transparently — if `ffplay` is unavailable, `SoundPlayer` is used as before.
24
+
25
+ ### 🎉 Party Mode Cross-Platform Entry Point
26
+ BMAD party mode step files and the Copilot skill now consistently reference
27
+ `node bin/bmad-speak.js` — the single cross-platform entry point that delegates to
28
+ `bmad-speak.ps1` on Windows and `bmad-speak.sh` elsewhere.
29
+
30
+ ### 🔧 Other Fixes
31
+ - `play-tts.sh` now accepts a named `--llm <key>` flag in addition to the `LLM_PROVIDER` env var
32
+ - `mcp-server/server.py` routes `AGENTVIBES_LLM` → `CLAUDECODE=1` → `AGENTVIBES_MCP_FALLBACK`
33
+ priority chain and forwards the resolved key as `-llm`/`--llm` to TTS scripts
34
+ - Added `audio-effects.cfg` rows for `llm:claude-code`, `llm:copilot`, `llm:codex`
35
+ - Added `command-routing.test.js` and `ConfigService` unit tests
36
+ - npm pack content guard now catches untracked publishable files
37
+
38
+ ### 📊 Technical
39
+ - 231 tests passing (0 failures)
40
+
41
+ ---
42
+
3
43
  ## 🎛️ v5.4.0 — TUI Installer, Spinner Fix & Dependency Cleanup
4
44
 
5
45
  **Released:** 2026-04-22
package/bin/agentvibes.js CHANGED
@@ -75,6 +75,16 @@ export function resolveStartTab(args, configService) {
75
75
  return { startTab: 'install' };
76
76
  }
77
77
 
78
+ if (cmd === 'update') {
79
+ // Always route update to CLI installer (src/installer.js)
80
+ return { cliUpdate: true, args: args.slice(1) };
81
+ }
82
+
83
+ if (cmd === 'uninstall') {
84
+ // Always route uninstall to CLI installer (src/installer.js)
85
+ return { cliUninstall: true, args: args.slice(1) };
86
+ }
87
+
78
88
  if (cmd === 'config' || cmd === 'configure') {
79
89
  return { startTab: 'settings' };
80
90
  }
@@ -154,6 +164,24 @@ if (_argv1 === _thisFile) {
154
164
  process.exit(0);
155
165
  }
156
166
 
167
+ if (result.cliUpdate) {
168
+ const installerPath = path.resolve(__dirname, '..', 'src', 'installer.js');
169
+ execFileSync(process.execPath, [installerPath, 'update', ...result.args], {
170
+ stdio: 'inherit',
171
+ shell: false,
172
+ });
173
+ process.exit(0);
174
+ }
175
+
176
+ if (result.cliUninstall) {
177
+ const installerPath = path.resolve(__dirname, '..', 'src', 'installer.js');
178
+ execFileSync(process.execPath, [installerPath, 'uninstall', ...result.args], {
179
+ stdio: 'inherit',
180
+ shell: false,
181
+ });
182
+ process.exit(0);
183
+ }
184
+
157
185
  launchConsole({ startTab: result.startTab }).catch(err => {
158
186
  process.stderr.write(`Failed to launch AgentVibes console: ${err.message}\n`);
159
187
  process.exit(1);
@@ -192,6 +192,17 @@ class AgentVibesServer:
192
192
  original_language = await self._get_language()
193
193
  await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["set", language])
194
194
 
195
+ # Resolve LLM key: AGENTVIBES_LLM > CLAUDECODE=1 > AGENTVIBES_MCP_FALLBACK > "default"
196
+ llm_key = os.environ.get("AGENTVIBES_LLM", "").strip()
197
+ if llm_key and not _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", llm_key):
198
+ llm_key = ""
199
+ if not llm_key and os.environ.get("CLAUDECODE", "").strip() == "1":
200
+ llm_key = "claude-code"
201
+ if not llm_key:
202
+ fallback = os.environ.get("AGENTVIBES_MCP_FALLBACK", "").strip()
203
+ if fallback and _re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$", fallback):
204
+ llm_key = fallback
205
+
195
206
  # Call the TTS script via appropriate shell
196
207
  tts_script = "play-tts.ps1" if self.is_windows else "play-tts.sh"
197
208
  play_tts = self.hooks_dir / tts_script
@@ -199,8 +210,13 @@ class AgentVibesServer:
199
210
  args = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(play_tts), text]
200
211
  if voice:
201
212
  args.extend(["-VoiceOverride", voice])
213
+ if llm_key:
214
+ args.extend(["-llm", llm_key])
202
215
  else:
203
- args = ["bash", str(play_tts), text]
216
+ args = ["bash", str(play_tts)]
217
+ if llm_key:
218
+ args.extend(["--llm", llm_key])
219
+ args.append(text)
204
220
  if voice:
205
221
  args.append(voice)
206
222
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "agentvibes",
4
- "version": "5.4.0",
4
+ "version": "5.5.0",
5
5
  "description": "Now your AI Agents can finally talk back! Professional TTS voice for Claude Code, Claude Desktop (via MCP), and Clawdbot with multi-provider support.",
6
6
  "homepage": "https://agentvibes.org",
7
7
  "keywords": [
@@ -185,12 +185,15 @@ export function scanTracks() {
185
185
  const tracksDir = _getTracksDir();
186
186
  try {
187
187
  const files = fs.readdirSync(tracksDir);
188
+ const mp3s = files.filter(f => /\.mp3$/i.test(f));
189
+ // If the directory exists but has no mp3s (e.g. empty npm package dir),
190
+ // fall back to the static catalog so bundled tracks always show.
191
+ if (mp3s.length === 0) return BUILT_IN_TRACK_CATALOG.map(t => ({ ...t, isBuiltIn: true }));
188
192
  const builtInIds = new Set(BUILT_IN_TRACK_CATALOG.map(t => t.id));
189
193
  // Sort by the alphabetic part of the label (skip leading emoji/symbols)
190
194
  // so the order reflects the track NAME, not the emoji codepoint.
191
195
  const _sortKey = (s) => s.replace(/^[^a-zA-Z]+/, '');
192
- return files
193
- .filter(f => /\.mp3$/i.test(f))
196
+ return mp3s
194
197
  .map(f => ({ id: f, label: formatTrackLabel(f), isBuiltIn: builtInIds.has(f) }))
195
198
  .sort((a, b) => _sortKey(a.label).localeCompare(_sortKey(b.label), undefined, { sensitivity: 'base' }));
196
199
  } catch {
@@ -133,6 +133,10 @@ function loadCatalog() {
133
133
  // Build lookup map for O(1) access by voiceId
134
134
  _catalogMap = new Map();
135
135
  for (const c of _catalogEntries) _catalogMap.set(c.voiceId, c);
136
+
137
+ // Patch libritts_r onnx.json files so their speaker IDs become friendly names.
138
+ // Must run after catalog loads so the name mapping is available.
139
+ patchLibriTTSSpeakerNames();
136
140
  }
137
141
 
138
142
  /**
@@ -142,45 +146,48 @@ function loadCatalog() {
142
146
  * Safe to call multiple times — skips if already patched.
143
147
  */
144
148
  function patchLibriTTSSpeakerNames() {
149
+ // Load catalog once for all models
150
+ const catalogPath = path.resolve(__dirname, '..', '..', '..', 'voice-assignments.json');
151
+ if (!fs.existsSync(catalogPath)) return;
152
+ let speakers;
145
153
  try {
146
- const jsonPath = path.join(PIPER_VOICES_DIR, 'en_US-libritts-high.onnx.json');
147
- if (!fs.existsSync(jsonPath)) return;
148
- const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
149
- if (!data.speaker_id_map || data.num_speakers <= 1) return;
150
-
151
- const names = Object.keys(data.speaker_id_map);
152
- // Already patched if first name doesn't start with 'p' followed by digits
153
- if (names.length > 0 && !/^p\d+$/.test(names[0])) return;
154
-
155
- // Build index p-name reverse map
156
- const indexToP = {};
157
- for (const [pname, idx] of Object.entries(data.speaker_id_map)) {
158
- indexToP[idx] = pname;
159
- }
154
+ speakers = JSON.parse(fs.readFileSync(catalogPath, 'utf8')).libritts_speakers ?? {};
155
+ } catch { return; }
156
+
157
+ // Models to patch and how to detect unpatched keys:
158
+ // libritts-high → raw keys are p-prefixed corpus IDs (p3922, p8699, …)
159
+ // libritts_r-* → raw keys are plain numeric corpus IDs (3922, 8699, …)
160
+ const MODELS = [
161
+ { file: 'en_US-libritts-high.onnx.json', notPatched: (k) => /^p\d+$/.test(k) },
162
+ { file: 'en_US-libritts_r-medium.onnx.json', notPatched: (k) => /^\d+$/.test(k) },
163
+ { file: 'en_US-libritts_r-high.onnx.json', notPatched: (k) => /^\d+$/.test(k) },
164
+ ];
165
+
166
+ for (const { file, notPatched } of MODELS) {
167
+ try {
168
+ const jsonPath = path.join(PIPER_VOICES_DIR, file);
169
+ if (!fs.existsSync(jsonPath)) continue;
170
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
171
+ if (!data.speaker_id_map || data.num_speakers <= 1) continue;
160
172
 
161
- // Load friendly names from catalog
162
- const catalogPath = path.resolve(__dirname, '..', '..', '..', 'voice-assignments.json');
163
- if (!fs.existsSync(catalogPath)) return;
164
- const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
165
- const speakers = catalog.libritts_speakers ?? {};
166
-
167
- // Rebuild speaker_id_map with friendly names
168
- const newMap = {};
169
- for (const [idx, pname] of Object.entries(indexToP)) {
170
- const friendly = speakers[idx]?.voice_name;
171
- if (friendly) {
172
- newMap[friendly] = parseInt(idx, 10);
173
- } else {
174
- newMap[pname] = parseInt(idx, 10);
173
+ const names = Object.keys(data.speaker_id_map);
174
+ // Skip if already patched (first key is a friendly name, not a raw corpus ID)
175
+ if (names.length === 0 || !notPatched(names[0])) continue;
176
+
177
+ // Values are 0-based sequential indices into the model — use as catalog key
178
+ const newMap = {};
179
+ for (const [rawKey, idx] of Object.entries(data.speaker_id_map)) {
180
+ const friendly = speakers[String(idx)]?.voice_name;
181
+ newMap[friendly ?? rawKey] = idx;
175
182
  }
176
- }
177
183
 
178
- data.speaker_id_map = newMap;
179
- // Verify file ownership before writing (security: CLAUDE.md)
180
- const stat = fs.statSync(jsonPath);
181
- if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return;
182
- fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2), 'utf8');
183
- } catch { /* non-fatal */ }
184
+ data.speaker_id_map = newMap;
185
+ // Verify file ownership before writing (security: CLAUDE.md)
186
+ const stat = fs.statSync(jsonPath);
187
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) continue;
188
+ fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2), 'utf8');
189
+ } catch { /* non-fatal — skip this model */ }
190
+ }
184
191
  }
185
192
 
186
193
  // Column widths for the multi-column voice list
@@ -417,9 +424,9 @@ export function parseMultiSpeaker(voiceId) {
417
424
  try {
418
425
  const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
419
426
  let speakerId = data.speaker_id_map?.[speakerName] ?? null;
420
- // Fallback: if the .onnx.json still has raw p-names (not yet patched),
427
+ // Fallback: if the .onnx.json still has raw corpus IDs (not yet patched),
421
428
  // look up the numeric speaker ID from voice-assignments.json catalog.
422
- if (speakerId == null && model === 'en_US-libritts-high') {
429
+ if (speakerId == null && (model === 'en_US-libritts-high' || /^en_US-libritts_r-/.test(model))) {
423
430
  try {
424
431
  const catalogPath = path.resolve(__dirname, '..', '..', '..', 'voice-assignments.json');
425
432
  const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
@@ -443,6 +450,9 @@ export function parseMultiSpeaker(voiceId) {
443
450
  * @returns {string[]}
444
451
  */
445
452
  export function scanInstalledVoices() {
453
+ // Ensure catalog is loaded and libritts_r onnx.json files are patched
454
+ // before we read their speaker_id_map keys (otherwise we get raw corpus IDs).
455
+ loadCatalog();
446
456
  try {
447
457
  const files = fs.readdirSync(PIPER_VOICES_DIR);
448
458
  const onnxFiles = files
@@ -588,6 +598,30 @@ export function getVoiceMeta(voiceId) {
588
598
  _metaCache.set(voiceId, result);
589
599
  return result;
590
600
  }
601
+ // libritts_r variants share speaker names with libritts-high after patching
602
+ if (/^en_US-libritts_r-/.test(ms.model)) {
603
+ // After patching: speakerName is a friendly name — look up in libritts-high catalog
604
+ const highCat = _catalogMap.get(`en_US-libritts-high${MS_SEP}${ms.speakerName}`);
605
+ if (highCat) {
606
+ const result = { displayName: highCat.displayName, gender: highCat.gender, provider: `Piper (${ms.model})` };
607
+ _metaCache.set(voiceId, result);
608
+ return result;
609
+ }
610
+ // Before patching: speakerName is a raw numeric corpus ID — resolve via onnx.json value
611
+ if (/^\d+$/.test(ms.speakerName)) {
612
+ try {
613
+ const jsonPath = path.join(PIPER_VOICES_DIR, ms.model + '.onnx.json');
614
+ const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
615
+ const seqIdx = data.speaker_id_map?.[ms.speakerName];
616
+ if (seqIdx != null && _catalogEntries[seqIdx]) {
617
+ const cat = _catalogEntries[seqIdx];
618
+ const result = { displayName: cat.displayName, gender: cat.gender, provider: `Piper (${ms.model})` };
619
+ _metaCache.set(voiceId, result);
620
+ return result;
621
+ }
622
+ } catch { /* fall through */ }
623
+ }
624
+ }
591
625
  // Fallback for speakers not in the catalog (e.g. 16Speakers model)
592
626
  const displayName = uniquifyVoiceName(ms.speakerName.replace(/_/g, ' '));
593
627
  const result = {
package/src/installer.js CHANGED
@@ -3249,6 +3249,7 @@ async function handleTermuxSshConfiguration() {
3249
3249
  * @returns {Promise<{count: number, boxen: string}>} Number of files copied and boxen content
3250
3250
  */
3251
3251
  async function copyCommandFiles(targetDir, spinner) {
3252
+ spinner = createRobustSpinner(spinner);
3252
3253
  spinner.start('Installing /agent-vibes slash commands...');
3253
3254
  const srcCommandsDir = path.join(__dirname, '..', '.claude', 'commands', 'agent-vibes');
3254
3255
  const commandsDir = path.join(targetDir, '.claude', 'commands');
@@ -3462,6 +3463,7 @@ function buildHookInstallationBoxen(installedFiles, failedFiles) {
3462
3463
  * @returns {Promise<{count: number, boxen: string|null}>} Number of files copied and boxen content
3463
3464
  */
3464
3465
  async function copyHookFiles(targetDir, spinner) {
3466
+ spinner = createRobustSpinner(spinner);
3465
3467
  spinner.start('Installing TTS helper scripts...');
3466
3468
  const hooksSubdir = isNativeWindows() ? 'hooks-windows' : 'hooks';
3467
3469
  const srcHooksDir = path.join(__dirname, '..', '.claude', hooksSubdir);
@@ -3516,6 +3518,7 @@ async function copyHookFiles(targetDir, spinner) {
3516
3518
  * @returns {Promise<{count: number, boxen: string|null}>} Number of files copied and boxen content
3517
3519
  */
3518
3520
  async function copyPersonalityFiles(targetDir, spinner) {
3521
+ spinner = createRobustSpinner(spinner);
3519
3522
  spinner.start('Installing personality templates...');
3520
3523
  const srcPersonalitiesDir = path.join(__dirname, '..', '.claude', 'personalities');
3521
3524
  const destPersonalitiesDir = path.join(targetDir, '.claude', 'personalities');
@@ -3598,6 +3601,7 @@ async function copyPersonalityFiles(targetDir, spinner) {
3598
3601
  * @returns {Promise<number>} Number of files copied
3599
3602
  */
3600
3603
  async function copyPluginFiles(targetDir, spinner) {
3604
+ spinner = createRobustSpinner(spinner);
3601
3605
  spinner.start('Installing BMAD plugin files...');
3602
3606
  const srcPluginsDir = path.join(__dirname, '..', '.claude', 'plugins');
3603
3607
  const destPluginsDir = path.join(targetDir, '.claude', 'plugins');
@@ -3632,6 +3636,7 @@ async function copyPluginFiles(targetDir, spinner) {
3632
3636
  * @returns {Promise<number>} Number of files copied
3633
3637
  */
3634
3638
  async function copyBmadConfigFiles(targetDir, spinner) {
3639
+ spinner = createRobustSpinner(spinner);
3635
3640
  spinner.start('Installing BMAD config files...');
3636
3641
  const srcBmadDir = path.join(__dirname, '..', '.agentvibes', 'bmad');
3637
3642
  const destBmadDir = path.join(targetDir, '.agentvibes', 'bmad');
@@ -3664,6 +3669,7 @@ async function copyBmadConfigFiles(targetDir, spinner) {
3664
3669
  * @returns {Promise<{count: number, boxen: string}>} Number of files copied and boxen content
3665
3670
  */
3666
3671
  async function copyBackgroundMusicFiles(targetDir, spinner) {
3672
+ spinner = createRobustSpinner(spinner);
3667
3673
  spinner.start('Installing background music tracks...');
3668
3674
  const srcBackgroundsDir = path.join(__dirname, '..', '.claude', 'audio', 'tracks');
3669
3675
  const destBackgroundsDir = path.join(targetDir, '.claude', 'audio', 'tracks');
@@ -3786,6 +3792,7 @@ async function copyBackgroundMusicFiles(targetDir, spinner) {
3786
3792
  * @returns {Promise<number>} Number of files copied
3787
3793
  */
3788
3794
  async function copyConfigFiles(targetDir, spinner) {
3795
+ spinner = createRobustSpinner(spinner);
3789
3796
  spinner.start('Installing configuration files...');
3790
3797
  const srcConfigDir = path.join(__dirname, '..', '.claude', 'config');
3791
3798
  const destConfigDir = path.join(targetDir, '.claude', 'config');
@@ -3848,6 +3855,7 @@ async function copyConfigFiles(targetDir, spinner) {
3848
3855
  * @param {Object} spinner - Ora spinner instance
3849
3856
  */
3850
3857
  async function copyCodexFiles(targetDir, spinner) {
3858
+ spinner = createRobustSpinner(spinner);
3851
3859
  spinner.start('Installing Codex integration files...');
3852
3860
  const srcCodexDir = path.join(__dirname, '..', '.codex');
3853
3861
  const destCodexDir = path.join(targetDir, '.codex');
@@ -3901,6 +3909,7 @@ async function copyCodexFiles(targetDir, spinner) {
3901
3909
  * @param {Object} spinner - Ora spinner instance
3902
3910
  */
3903
3911
  async function configureSessionStartHook(targetDir, spinner) {
3912
+ spinner = createRobustSpinner(spinner);
3904
3913
  spinner.start('Configuring AgentVibes hook for automatic TTS...');
3905
3914
  const claudeDir = path.join(targetDir, '.claude');
3906
3915
  const settingsPath = path.join(claudeDir, 'settings.json');
@@ -3956,6 +3965,7 @@ async function configureSessionStartHook(targetDir, spinner) {
3956
3965
  * @param {Object} spinner - Ora spinner instance
3957
3966
  */
3958
3967
  async function configurePartyModeHook(targetDir, spinner, homeDirOverride) {
3968
+ spinner = createRobustSpinner(spinner);
3959
3969
  spinner.start('Configuring BMAD party mode TTS hook...');
3960
3970
  const homeDir = homeDirOverride || os.homedir();
3961
3971
  const globalClaudeDir = path.join(homeDir, '.claude');
@@ -198,7 +198,7 @@ export async function installClaudeMcp(targetDir) {
198
198
 
199
199
  try {
200
200
  // Copy hooks, commands, config, personality, plugin, bmad config files
201
- const silentSpinner = { start: () => {}, succeed: () => {}, fail: () => {} };
201
+ const silentSpinner = { start: () => {}, stop: () => {}, succeed: () => {}, fail: () => {}, warn: () => {}, info: () => {}, stopAndPersist: () => {}, get text() { return ''; }, set text(_) {}, get isSpinning() { return false; } };
202
202
  const installer = await import('../installer.js');
203
203
  await installer.copyHookFiles(targetDir, silentSpinner);
204
204
  await installer.copyCommandFiles(targetDir, silentSpinner);