agentvibes 4.6.8 → 5.1.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.
Files changed (40) hide show
  1. package/.agentvibes/bmad-voice-map.json +104 -0
  2. package/.agentvibes/config.json +13 -12
  3. package/.agentvibes/copilot-sessions.log +4 -0
  4. package/.claude/audio/tracks/Drifting Down the Hall.mp3 +0 -0
  5. package/.claude/audio/tracks/Late Night Hip Hop Groove.mp3 +0 -0
  6. package/.claude/audio/tracks/Midnight Charleston Stomp.mp3 +0 -0
  7. package/.claude/audio/tracks/README.md +51 -52
  8. package/.claude/config/audio-effects-bmad.cfg +50 -0
  9. package/.claude/config/audio-effects.cfg +4 -4
  10. package/.claude/config/background-music-enabled.txt +1 -0
  11. package/.claude/config/personality.txt +1 -0
  12. package/.claude/hooks/play-tts-piper.sh +3 -1
  13. package/.claude/hooks/play-tts.sh +380 -301
  14. package/.claude/hooks/session-start-tts.sh +81 -81
  15. package/.claude/hooks-windows/audio-processor.ps1 +181 -0
  16. package/.claude/hooks-windows/play-tts-piper.ps1 +259 -245
  17. package/.claude/hooks-windows/play-tts.ps1 +28 -6
  18. package/.claude/hooks-windows/session-start-tts.ps1 +114 -114
  19. package/README.md +112 -6
  20. package/RELEASE_NOTES.md +83 -0
  21. package/bin/bmad-speak.js +16 -8
  22. package/mcp-server/server.py +15 -8
  23. package/package.json +1 -1
  24. package/src/console/app.js +899 -897
  25. package/src/console/footer-config.js +50 -50
  26. package/src/console/navigation.js +65 -65
  27. package/src/console/tabs/agents-tab.js +1899 -1886
  28. package/src/console/tabs/music-tab.js +1076 -1039
  29. package/src/console/tabs/placeholder-tab.js +81 -80
  30. package/src/console/tabs/settings-tab.js +941 -3988
  31. package/src/console/tabs/setup-tab.js +2071 -0
  32. package/src/console/tabs/voices-tab.js +1843 -1714
  33. package/src/console/widgets/format-utils.js +92 -89
  34. package/src/console/widgets/track-picker.js +325 -322
  35. package/src/installer.js +6147 -6092
  36. package/src/services/llm-provider-service.js +486 -0
  37. package/src/services/navigation-service.js +123 -123
  38. package/src/services/tts-engine-service.js +69 -0
  39. package/.claude/audio/tracks/dreamy_house_loop.mp3 +0 -0
  40. package/src/console/tabs/install-tab.js +0 -1081
@@ -1,245 +1,259 @@
1
- #
2
- # File: .claude/hooks-windows/play-tts-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
- # Try provider-specific file first, then generic tts-voice.txt (set by TUI)
29
- $VoiceFile = "$ClaudeDir\tts-voice-piper.txt"
30
- if (-not (Test-Path $VoiceFile)) {
31
- $VoiceFile = "$ClaudeDir\tts-voice.txt"
32
- }
33
-
34
- # Voices and Piper binary are global (shared across projects, ~100MB+)
35
- $UserClaudeDir = "$env:USERPROFILE\.claude"
36
- $VoicesDir = "$UserClaudeDir\piper-voices"
37
- # Try standard install location first, then fall back to PATH
38
- $PiperExe = "$env:LOCALAPPDATA\Programs\Piper\piper.exe"
39
- if (-not (Test-Path $PiperExe)) {
40
- $found = Get-Command piper.exe -ErrorAction SilentlyContinue
41
- if (-not $found) {
42
- # PATH may be stale (SSH sessions inherit minimal PATH); refresh from registry
43
- $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
44
- $found = Get-Command piper.exe -ErrorAction SilentlyContinue
45
- }
46
- if ($found) { $PiperExe = $found.Source }
47
- }
48
-
49
- # Ensure directories exist
50
- foreach ($dir in @($AudioDir, $VoicesDir)) {
51
- if (-not (Test-Path $dir)) {
52
- New-Item -ItemType Directory -Path $dir -Force | Out-Null
53
- }
54
- }
55
-
56
- # Check if Piper is installed
57
- if (-not (Test-Path $PiperExe)) {
58
- Write-Host "[ERROR] Piper not found at: $PiperExe" -ForegroundColor Red
59
- Write-Host "Run: .\setup-windows.ps1 to install Piper" -ForegroundColor Yellow
60
- exit 1
61
- }
62
-
63
- # Determine voice to use
64
- $VoiceName = ""
65
-
66
- if ($VoiceOverride) {
67
- $VoiceName = $VoiceOverride
68
- }
69
- elseif (Test-Path $VoiceFile) {
70
- $VoiceName = (Get-Content $VoiceFile -Raw).Trim()
71
- }
72
-
73
- # Strip display name suffix (e.g. "en_US-libritts-high::Holly-7" -> "en_US-libritts-high")
74
- # and resolve the real Piper speaker index.
75
- # IMPORTANT: The trailing number in a speaker name (e.g. "Holly-7") is a disambiguation
76
- # suffix, NOT the speaker index. Real index must be looked up from voice-assignments.json.
77
- if ($VoiceName -match '::') {
78
- $parts = $VoiceName -split '::'
79
- $VoiceName = $parts[0]
80
- $SpeakerName = if ($parts.Length -ge 2) { $parts[1] } else { "" }
81
- Remove-Item env:PIPER_SPEAKER -ErrorAction SilentlyContinue
82
-
83
- if ($SpeakerName) {
84
- # Primary: look up in voice-assignments.json catalog (libritts_speakers keyed by speaker index)
85
- # Derive project root from this script's location: .claude/hooks-windows/ -> project root
86
- $PiperScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
87
- $PiperProjectRoot = Split-Path -Parent (Split-Path -Parent $PiperScriptRoot)
88
- $VoiceAssignmentsPath = Join-Path $PiperProjectRoot "voice-assignments.json"
89
- # Fallback: global AgentVibes install if not found in project
90
- if (-not (Test-Path $VoiceAssignmentsPath)) {
91
- $VoiceAssignmentsPath = Join-Path $env:USERPROFILE "AgentVibes\voice-assignments.json"
92
- }
93
- $SpeakerResolved = $false
94
- if (Test-Path $VoiceAssignmentsPath) {
95
- try {
96
- $vaData = Get-Content $VoiceAssignmentsPath -Raw | ConvertFrom-Json
97
- foreach ($prop in $vaData.libritts_speakers.PSObject.Properties) {
98
- if ($prop.Value.voice_name -eq $SpeakerName) {
99
- $env:PIPER_SPEAKER = $prop.Name
100
- $SpeakerResolved = $true
101
- break
102
- }
103
- }
104
- } catch { }
105
- }
106
- # Fallback: check patched speaker_id_map in the .onnx.json
107
- if (-not $SpeakerResolved) {
108
- $OnnxJsonPath = "$VoicesDir\$VoiceName.onnx.json"
109
- if (Test-Path $OnnxJsonPath) {
110
- try {
111
- $onnxData = Get-Content $OnnxJsonPath -Raw | ConvertFrom-Json
112
- $speakerIdMap = $onnxData.speaker_id_map
113
- if ($speakerIdMap -and $speakerIdMap.PSObject.Properties[$SpeakerName]) {
114
- $env:PIPER_SPEAKER = [string]$speakerIdMap.PSObject.Properties[$SpeakerName].Value
115
- }
116
- } catch { }
117
- }
118
- }
119
- }
120
- } else {
121
- # No multi-speaker syntax — clear any stale speaker env var
122
- Remove-Item env:PIPER_SPEAKER -ErrorAction SilentlyContinue
123
- }
124
-
125
- # Default voice if not specified
126
- # Prefer en_US-lessac-medium (bundled/commonly installed) over en_US-ryan-high
127
- if (-not $VoiceName) {
128
- $UserClaudePiperDir = "$env:USERPROFILE\.claude\piper-voices"
129
- if (Test-Path "$UserClaudePiperDir\en_US-lessac-medium.onnx") {
130
- $VoiceName = "en_US-lessac-medium"
131
- } elseif (Test-Path "$UserClaudePiperDir\en_US-ryan-high.onnx") {
132
- $VoiceName = "en_US-ryan-high"
133
- } else {
134
- # Fallback: use first available .onnx file, or default name for auto-download
135
- $firstVoice = Get-ChildItem -Path $UserClaudePiperDir -Filter "*.onnx" -ErrorAction SilentlyContinue | Select-Object -First 1
136
- if ($firstVoice) {
137
- $VoiceName = $firstVoice.BaseName
138
- } else {
139
- $VoiceName = "en_US-lessac-medium"
140
- }
141
- }
142
- }
143
-
144
- # Security: Validate voice name to prevent path traversal
145
- # Only allow alphanumeric, underscore, hyphen, and period
146
- if ($VoiceName -notmatch '^[a-zA-Z0-9_\-\.]+$') {
147
- Write-Host "[ERROR] Invalid voice name: $VoiceName" -ForegroundColor Red
148
- exit 1
149
- }
150
-
151
- # Resolve voice model path and validate it stays within VoicesDir
152
- $VoiceModelFile = [System.IO.Path]::GetFullPath("$VoicesDir\$VoiceName.onnx")
153
- $VoiceJsonFile = [System.IO.Path]::GetFullPath("$VoicesDir\$VoiceName.onnx.json")
154
- $ResolvedVoicesDir = [System.IO.Path]::GetFullPath($VoicesDir)
155
- if (-not $VoiceModelFile.StartsWith($ResolvedVoicesDir)) {
156
- Write-Host "[ERROR] Voice path outside voices directory" -ForegroundColor Red
157
- exit 1
158
- }
159
-
160
- # Check if voice model exists, download if missing
161
- if (-not (Test-Path $VoiceModelFile)) {
162
- Write-Host "[DOWNLOAD] Voice model: $VoiceName" -ForegroundColor Yellow
163
-
164
- # Try to download from Hugging Face
165
- # Voice name format: {lang}_{region}-{speaker}-{quality}
166
- # HF path format: {lang}/{lang}_{region}/{speaker}/{quality}/{voicename}.onnx
167
- try {
168
- # Parse voice name to build correct HF path
169
- # e.g. en_US-ryan-high -> en/en_US/ryan/high/en_US-ryan-high.onnx
170
- if ($VoiceName -match '^([a-z]{2})_([A-Z]{2})-([a-zA-Z0-9_]+)-([a-z]+)$') {
171
- $Lang = $Matches[1]
172
- $LangRegion = "$($Matches[1])_$($Matches[2])"
173
- $Speaker = $Matches[3]
174
- $Quality = $Matches[4]
175
- $HFBase = "https://huggingface.co/rhasspy/piper-voices/resolve/main/$Lang/$LangRegion/$Speaker/$Quality"
176
- } else {
177
- # Fallback for non-standard voice names
178
- $HFBase = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ryan/high"
179
- }
180
- $ModelUrl = "$HFBase/$VoiceName.onnx"
181
- $JsonUrl = "$HFBase/$VoiceName.onnx.json"
182
-
183
- Write-Host " Downloading model..." -ForegroundColor Cyan
184
- Invoke-WebRequest -Uri $ModelUrl -OutFile $VoiceModelFile -ErrorAction Stop
185
- Write-Host " Downloading config..." -ForegroundColor Cyan
186
- Invoke-WebRequest -Uri $JsonUrl -OutFile $VoiceJsonFile -ErrorAction Stop
187
- Write-Host "[OK] Voice model downloaded" -ForegroundColor Green
188
- }
189
- catch {
190
- Write-Host "[ERROR] Failed to download voice model: $_" -ForegroundColor Red
191
- Write-Host "Make sure you have internet connection" -ForegroundColor Yellow
192
- exit 1
193
- }
194
- }
195
-
196
- # Sanitize text for speech - strip only dangerous shell metacharacters
197
- $Text = $Text -replace '\\', ' '
198
- $Text = $Text -replace '[{}<>|`~^;]', ''
199
- $Text = $Text -replace '\s+', ' '
200
- $Text = $Text.Trim()
201
-
202
- # Create audio file path SECURITY: use random name instead of predictable timestamp (#130)
203
- $AudioFile = "$AudioDir\tts-$([System.IO.Path]::GetRandomFileName() -replace '\..*').wav"
204
-
205
- # Synthesize with Piper
206
- try {
207
- Write-Host "[SYNTH] Synthesizing with Piper..." -ForegroundColor Cyan
208
-
209
- # Run Piper with text input
210
- # Add --speaker for multi-speaker models (e.g. libritts-high with speaker 9)
211
- $piperArgs = @("--model", $VoiceModelFile, "--output-file", $AudioFile)
212
- if ($env:PIPER_SPEAKER) {
213
- $piperArgs += @("--speaker", $env:PIPER_SPEAKER)
214
- }
215
- $Text | & $PiperExe @piperArgs 2>$null
216
-
217
- if (-not (Test-Path $AudioFile)) {
218
- Write-Host "[ERROR] Piper synthesis failed" -ForegroundColor Red
219
- exit 1
220
- }
221
-
222
- # Display results
223
- Write-Host "[OK] Saved to: $AudioFile" -ForegroundColor Green
224
- Write-Host "[VOICE] Voice used: $VoiceName (Piper)" -ForegroundColor Green
225
-
226
- # Play the audio using built-in Windows audio player (skip if AGENTVIBES_NO_PLAY is set)
227
- if (-not $env:AGENTVIBES_NO_PLAY) {
228
- $player = $null
229
- try {
230
- $player = New-Object System.Media.SoundPlayer $AudioFile
231
- $player.PlaySync()
232
- }
233
- catch {
234
- Write-Host "[WARNING] Could not play audio (SoundPlayer unavailable)" -ForegroundColor Yellow
235
- Write-Host "Audio saved to: $AudioFile" -ForegroundColor Gray
236
- }
237
- finally {
238
- if ($player) { $player.Dispose() }
239
- }
240
- }
241
- }
242
- catch {
243
- Write-Host "[ERROR] Error running Piper: $_" -ForegroundColor Red
244
- exit 1
245
- }
1
+ #
2
+ # File: .claude/hooks-windows/play-tts-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
+ # Try provider-specific file first, then generic tts-voice.txt (set by TUI)
29
+ $VoiceFile = "$ClaudeDir\tts-voice-piper.txt"
30
+ if (-not (Test-Path $VoiceFile)) {
31
+ $VoiceFile = "$ClaudeDir\tts-voice.txt"
32
+ }
33
+
34
+ # Voices and Piper binary are global (shared across projects, ~100MB+)
35
+ $UserClaudeDir = "$env:USERPROFILE\.claude"
36
+ $VoicesDir = "$UserClaudeDir\piper-voices"
37
+ # Try standard install location first, then fall back to PATH
38
+ $PiperExe = "$env:LOCALAPPDATA\Programs\Piper\piper.exe"
39
+ if (-not (Test-Path $PiperExe)) {
40
+ $found = Get-Command piper.exe -ErrorAction SilentlyContinue
41
+ if (-not $found) {
42
+ # PATH may be stale (SSH sessions inherit minimal PATH); refresh from registry
43
+ $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
44
+ $found = Get-Command piper.exe -ErrorAction SilentlyContinue
45
+ }
46
+ if ($found) { $PiperExe = $found.Source }
47
+ }
48
+
49
+ # Ensure directories exist
50
+ foreach ($dir in @($AudioDir, $VoicesDir)) {
51
+ if (-not (Test-Path $dir)) {
52
+ New-Item -ItemType Directory -Path $dir -Force | Out-Null
53
+ }
54
+ }
55
+
56
+ # Check if Piper is installed
57
+ if (-not (Test-Path $PiperExe)) {
58
+ Write-Host "[ERROR] Piper not found at: $PiperExe" -ForegroundColor Red
59
+ Write-Host "Run: .\setup-windows.ps1 to install Piper" -ForegroundColor Yellow
60
+ exit 1
61
+ }
62
+
63
+ # Determine voice to use
64
+ $VoiceName = ""
65
+
66
+ if ($VoiceOverride) {
67
+ $VoiceName = $VoiceOverride
68
+ }
69
+ elseif (Test-Path $VoiceFile) {
70
+ $VoiceName = (Get-Content $VoiceFile -Raw).Trim()
71
+ }
72
+
73
+ # Strip display name suffix (e.g. "en_US-libritts-high::Holly-7" -> "en_US-libritts-high")
74
+ # and resolve the real Piper speaker index.
75
+ # IMPORTANT: The trailing number in a speaker name (e.g. "Holly-7") is a disambiguation
76
+ # suffix, NOT the speaker index. Real index must be looked up from voice-assignments.json.
77
+ $SpeakerId = $null
78
+
79
+ if ($VoiceName -match '::') {
80
+ $parts = $VoiceName -split '::'
81
+ $VoiceName = $parts[0]
82
+ $SpeakerName = if ($parts.Length -ge 2) { $parts[1] } else { "" }
83
+
84
+ if ($SpeakerName) {
85
+ # Primary: look up in voice-assignments.json catalog (libritts_speakers keyed by speaker index)
86
+ # Derive project root from this script's location: .claude/hooks-windows/ -> project root
87
+ $PiperScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
88
+ $PiperProjectRoot = Split-Path -Parent (Split-Path -Parent $PiperScriptRoot)
89
+ $VoiceAssignmentsPath = Join-Path $PiperProjectRoot "voice-assignments.json"
90
+ # Fallback: global AgentVibes install if not found in project
91
+ if (-not (Test-Path $VoiceAssignmentsPath)) {
92
+ $VoiceAssignmentsPath = Join-Path $env:USERPROFILE "AgentVibes\voice-assignments.json"
93
+ }
94
+ if (Test-Path $VoiceAssignmentsPath) {
95
+ try {
96
+ $vaData = Get-Content $VoiceAssignmentsPath -Raw | ConvertFrom-Json
97
+ # First pass: try exact match
98
+ foreach ($prop in $vaData.libritts_speakers.PSObject.Properties) {
99
+ if ($prop.Value.voice_name -eq $SpeakerName) {
100
+ $SpeakerId = $prop.Name
101
+ break
102
+ }
103
+ }
104
+ # If no exact match, try substring match as fallback
105
+ if (-not $SpeakerId) {
106
+ foreach ($prop in $vaData.libritts_speakers.PSObject.Properties) {
107
+ if ($prop.Value.voice_name -like "$SpeakerName*") {
108
+ $SpeakerId = $prop.Name
109
+ break
110
+ }
111
+ }
112
+ }
113
+ } catch { }
114
+ }
115
+ # Fallback: check patched speaker_id_map in the .onnx.json
116
+ if (-not $SpeakerId) {
117
+ $OnnxJsonPath = "$VoicesDir\$VoiceName.onnx.json"
118
+ if (Test-Path $OnnxJsonPath) {
119
+ try {
120
+ $onnxData = Get-Content $OnnxJsonPath -Raw | ConvertFrom-Json
121
+ $speakerIdMap = $onnxData.speaker_id_map
122
+ if ($speakerIdMap -and $speakerIdMap.PSObject.Properties[$SpeakerName]) {
123
+ $SpeakerId = [string]$speakerIdMap.PSObject.Properties[$SpeakerName].Value
124
+ }
125
+ } catch { }
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ # Default voice if not specified
132
+ # Prefer en_US-lessac-medium (bundled/commonly installed) over en_US-ryan-high
133
+ if (-not $VoiceName) {
134
+ $UserClaudePiperDir = "$env:USERPROFILE\.claude\piper-voices"
135
+ if (Test-Path "$UserClaudePiperDir\en_US-lessac-medium.onnx") {
136
+ $VoiceName = "en_US-lessac-medium"
137
+ } elseif (Test-Path "$UserClaudePiperDir\en_US-ryan-high.onnx") {
138
+ $VoiceName = "en_US-ryan-high"
139
+ } else {
140
+ # Fallback: use first available .onnx file, or default name for auto-download
141
+ $firstVoice = Get-ChildItem -Path $UserClaudePiperDir -Filter "*.onnx" -ErrorAction SilentlyContinue | Select-Object -First 1
142
+ if ($firstVoice) {
143
+ $VoiceName = $firstVoice.BaseName
144
+ } else {
145
+ $VoiceName = "en_US-lessac-medium"
146
+ }
147
+ }
148
+ }
149
+
150
+ # Security: Validate voice name to prevent path traversal
151
+ # Only allow alphanumeric, underscore, hyphen, and period
152
+ if ($VoiceName -notmatch '^[a-zA-Z0-9_\-\.]+$') {
153
+ Write-Host "[ERROR] Invalid voice name: $VoiceName" -ForegroundColor Red
154
+ exit 1
155
+ }
156
+
157
+ # Resolve voice model path and validate it stays within VoicesDir
158
+ $VoiceModelFile = [System.IO.Path]::GetFullPath("$VoicesDir\$VoiceName.onnx")
159
+ $VoiceJsonFile = [System.IO.Path]::GetFullPath("$VoicesDir\$VoiceName.onnx.json")
160
+ $ResolvedVoicesDir = [System.IO.Path]::GetFullPath($VoicesDir)
161
+ if (-not $VoiceModelFile.StartsWith($ResolvedVoicesDir)) {
162
+ Write-Host "[ERROR] Voice path outside voices directory" -ForegroundColor Red
163
+ exit 1
164
+ }
165
+
166
+ # Check if voice model exists, download if missing
167
+ if (-not (Test-Path $VoiceModelFile)) {
168
+ Write-Host "[DOWNLOAD] Voice model: $VoiceName" -ForegroundColor Yellow
169
+
170
+ # Try to download from Hugging Face
171
+ # Voice name format: {lang}_{region}-{speaker}-{quality}
172
+ # HF path format: {lang}/{lang}_{region}/{speaker}/{quality}/{voicename}.onnx
173
+ try {
174
+ # Parse voice name to build correct HF path
175
+ # e.g. en_US-ryan-high -> en/en_US/ryan/high/en_US-ryan-high.onnx
176
+ if ($VoiceName -match '^([a-z]{2})_([A-Z]{2})-([a-zA-Z0-9_]+)-([a-z]+)$') {
177
+ $Lang = $Matches[1]
178
+ $LangRegion = "$($Matches[1])_$($Matches[2])"
179
+ $Speaker = $Matches[3]
180
+ $Quality = $Matches[4]
181
+ $HFBase = "https://huggingface.co/rhasspy/piper-voices/resolve/main/$Lang/$LangRegion/$Speaker/$Quality"
182
+ } else {
183
+ # Fallback for non-standard voice names
184
+ $HFBase = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ryan/high"
185
+ }
186
+ $ModelUrl = "$HFBase/$VoiceName.onnx"
187
+ $JsonUrl = "$HFBase/$VoiceName.onnx.json"
188
+
189
+ Write-Host " Downloading model..." -ForegroundColor Cyan
190
+ Invoke-WebRequest -Uri $ModelUrl -OutFile $VoiceModelFile -ErrorAction Stop
191
+ Write-Host " Downloading config..." -ForegroundColor Cyan
192
+ Invoke-WebRequest -Uri $JsonUrl -OutFile $VoiceJsonFile -ErrorAction Stop
193
+ Write-Host "[OK] Voice model downloaded" -ForegroundColor Green
194
+ }
195
+ catch {
196
+ Write-Host "[ERROR] Failed to download voice model: $_" -ForegroundColor Red
197
+ Write-Host "Make sure you have internet connection" -ForegroundColor Yellow
198
+ exit 1
199
+ }
200
+ }
201
+
202
+ # Sanitize text for speech - strip only dangerous shell metacharacters
203
+ $Text = $Text -replace '\\', ' '
204
+ $Text = $Text -replace '[{}<>|`~^;]', ''
205
+ $Text = $Text -replace '\s+', ' '
206
+ $Text = $Text.Trim()
207
+
208
+ # Create audio file path — SECURITY: use random name instead of predictable timestamp (#130)
209
+ $AudioFile = "$AudioDir\tts-$([System.IO.Path]::GetRandomFileName() -replace '\..*').wav"
210
+
211
+ # Synthesize with Piper
212
+ try {
213
+ Write-Host "[SYNTH] Synthesizing with Piper..." -ForegroundColor Cyan
214
+
215
+ # Run Piper with text input
216
+ # Add --speaker for multi-speaker models (e.g. libritts-high with speaker 66 for Derek)
217
+ $piperArgs = @("--model", $VoiceModelFile, "--output-file", $AudioFile)
218
+ if ($SpeakerId) {
219
+ $piperArgs += @("--speaker", $SpeakerId)
220
+ }
221
+ $Text | & $PiperExe @piperArgs 2>$null
222
+
223
+ if (-not (Test-Path $AudioFile)) {
224
+ Write-Host "[ERROR] Piper synthesis failed" -ForegroundColor Red
225
+ exit 1
226
+ }
227
+
228
+ # Display results
229
+ Write-Host "[OK] Saved to: $AudioFile" -ForegroundColor Green
230
+ Write-Host "[VOICE] Voice used: $VoiceName (Piper)" -ForegroundColor Green
231
+
232
+ # Apply audio effects (reverb, background music) if processor script exists
233
+ $ProcessorScript = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "audio-processor.ps1"
234
+ $ProcessedFile = $AudioFile
235
+ if (Test-Path $ProcessorScript) {
236
+ # Lookup order: agent name → LLM key (from --llm) → default
237
+ $AgentName = if ($env:AGENTVIBES_AGENT_NAME) { $env:AGENTVIBES_AGENT_NAME } elseif ($env:AGENTVIBES_LLM_KEY) { $env:AGENTVIBES_LLM_KEY } else { "default" }
238
+ $EffectedFile = "$AudioFile.effected.wav"
239
+ try {
240
+ & $ProcessorScript $AudioFile $AgentName $EffectedFile
241
+ if ((Test-Path $EffectedFile) -and (Get-Item $EffectedFile).Length -gt 0) {
242
+ $ProcessedFile = $EffectedFile
243
+ Write-Host "[EFFECTS] Audio effects applied" -ForegroundColor Cyan
244
+ }
245
+ }
246
+ catch {
247
+ Write-Host "[WARNING] Audio effects processing skipped: $_" -ForegroundColor Yellow
248
+ }
249
+ }
250
+
251
+ # Return path to processed audio file for parent (play-tts.ps1) to handle playback
252
+ # This allows play-tts.ps1 to apply additional post-processing (reverb, background music)
253
+ # DO NOT play here - let play-tts.ps1 coordinate all audio playback
254
+ Write-Host "[OUTPUT] Processed audio: $ProcessedFile" -ForegroundColor Gray
255
+ }
256
+ catch {
257
+ Write-Host "[ERROR] Error running Piper: $_" -ForegroundColor Red
258
+ exit 1
259
+ }
@@ -133,6 +133,10 @@ $PreSynthWav = $env:AGENTVIBES_PRESYNTHESIZED_WAV
133
133
  $UsePreSynth = $PreSynthWav -and (Test-Path $PreSynthWav) -and
134
134
  (Get-Item $PreSynthWav -ErrorAction SilentlyContinue).Length -gt 0
135
135
 
136
+ # Tracks the path of the WAV the provider just synthesized.
137
+ # Used to defend against playing stale cached audio when synthesis silently fails.
138
+ $SynthesizedWavPath = $null
139
+
136
140
  # If background music or reverb enabled and ffmpeg available, tell provider to skip playback
137
141
  if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
138
142
  $env:AGENTVIBES_NO_PLAY = "1"
@@ -167,17 +171,25 @@ if ($UsePreSynth) {
167
171
  $providerOutput = & $ProviderScript $Text 6>&1 2>&1
168
172
  }
169
173
  # Re-emit preserving colors from InformationRecords (Write-Host output)
174
+ # Also extract the synthesized WAV path from the provider's OK line.
170
175
  foreach ($item in $providerOutput) {
176
+ $lineText = $null
171
177
  if ($item -is [System.Management.Automation.InformationRecord]) {
172
178
  $msg = $item.MessageData
173
179
  if ($msg -is [System.Management.Automation.HostInformationMessage]) {
174
180
  Write-Host $msg.Message -ForegroundColor $msg.ForegroundColor -NoNewline:$msg.NoNewLine
175
181
  if (-not $msg.NoNewLine) { Write-Host }
182
+ $lineText = $msg.Message
176
183
  } else {
177
184
  Write-Host "$item"
185
+ $lineText = "$item"
178
186
  }
179
187
  } else {
180
188
  Write-Host "$item"
189
+ $lineText = "$item"
190
+ }
191
+ if ($lineText -and $lineText -match '\[OK\]\s+Saved to:\s+(.+\.wav)') {
192
+ $SynthesizedWavPath = $Matches[1].Trim()
181
193
  }
182
194
  }
183
195
  } else {
@@ -199,13 +211,23 @@ if ($UsePreSynth) {
199
211
  if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
200
212
  Remove-Item env:AGENTVIBES_NO_PLAY -ErrorAction SilentlyContinue
201
213
 
202
- # Find the WAV to post-process: use pre-synthesized file if available, else most recent
214
+ # Find the WAV to post-process. Never fall back to "most recent on disk"
215
+ # because that plays a random stale cache file when synthesis silently fails.
203
216
  $AudioDir = "$ClaudeDir\audio"
204
- $RecentWav = if ($UsePreSynth) {
205
- Get-Item $PreSynthWav -ErrorAction SilentlyContinue
206
- } else {
207
- Get-ChildItem -Path $AudioDir -Filter "tts-*.wav" -ErrorAction SilentlyContinue |
208
- Sort-Object LastWriteTime -Descending | Select-Object -First 1
217
+ $RecentWav = $null
218
+ if ($UsePreSynth) {
219
+ $RecentWav = Get-Item $PreSynthWav -ErrorAction SilentlyContinue
220
+ }
221
+ elseif ($SynthesizedWavPath -and (Test-Path $SynthesizedWavPath)) {
222
+ $cand = Get-Item $SynthesizedWavPath -ErrorAction SilentlyContinue
223
+ if ($cand -and $cand.Length -gt 0) {
224
+ $RecentWav = $cand
225
+ }
226
+ }
227
+ if (-not $RecentWav) {
228
+ Write-Host "[ERROR] No fresh synthesized WAV - refusing stale cache" -ForegroundColor Red
229
+ Remove-Item env:AGENTVIBES_NO_PLAY -ErrorAction SilentlyContinue
230
+ exit 1
209
231
  }
210
232
 
211
233
  if ($RecentWav -and $RecentWav.Length -gt 0) {