agentvibes 4.5.7 → 4.6.2

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.
@@ -17,7 +17,7 @@ bmad-agent-tech-writer||agent_vibes_arabic_v2_loop.mp3|0.70
17
17
  # BMAD Agents - each with unique audio personality|||
18
18
  |||
19
19
  # PM John - upbeat, driving energy|||
20
- John|gain -1 equalizer 3000 1q +2|agentvibes_soft_flamenco_loop.mp3|0.30
20
+ John|gain -1 equalizer 3000 1q +2|agentvibes_soft_flamenco_loop.mp3|0.20
21
21
  |||
22
22
  # Architect Winston - deep, authoritative|||
23
23
  Winston|reverb 40 50 90 gain -2|agentvibes_soft_flamenco_loop.mp3|0.25
@@ -0,0 +1 @@
1
+ AgentVibes
@@ -310,7 +310,7 @@ mix_background() {
310
310
  fi
311
311
 
312
312
  ffmpeg -y -i "$voice" -ss "$start_pos" -stream_loop -1 -i "$background" \
313
- -filter_complex "[1:a]volume=${volume},afade=t=in:st=0:d=0.3,afade=t=out:st=${bg_fade_out_adjusted}:d=2[bg];[0:a]adelay=${voice_delay_ms}|${voice_delay_ms}[v];[v][bg]amix=inputs=2:duration=longest[out]" \
313
+ -filter_complex "[1:a]volume=${volume},afade=t=in:st=0:d=0.3,afade=t=out:st=${bg_fade_out_adjusted}:d=2[bg];[0:a]adelay=${voice_delay_ms}|${voice_delay_ms},volume=1.5[v];[v][bg]amix=inputs=2:duration=longest:normalize=0[out]" \
314
314
  -map "[out]" $audio_settings -t "$total_duration" "$output" 2>/dev/null || {
315
315
  echo "Warning: Background mixing failed, using voice only" >&2
316
316
  cp "$voice" "$output"
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # File: ~/.claude/hooks/bmad-party-speak.sh
4
+ #
5
+ # AgentVibes PostToolUse Hook - BMAD Party Mode TTS (Linux / macOS / WSL)
6
+ #
7
+ # Fires after every Agent tool call. Detects BMAD party mode agents by
8
+ # fingerprinting the prompt, extracts the agent display name, maps it to
9
+ # the canonical agent ID via the manifest, then calls bmad-speak.sh.
10
+ # Uses flock for cross-process audio serialization (no overlapping speech).
11
+ #
12
+ # Installed globally so it works in any BMAD project.
13
+ # Uses CLAUDE_PROJECT_DIR env var to locate the project manifest at runtime.
14
+ #
15
+ # Input: JSON on stdin (Claude Code PostToolUse payload)
16
+ #
17
+
18
+ set -euo pipefail
19
+
20
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ LOCK_FILE="/tmp/agentvibes-party-queue.lock"
22
+
23
+ # --- Read stdin ---
24
+ raw="$(cat)"
25
+ [[ -z "$raw" ]] && exit 0
26
+
27
+ # --- Parse all needed fields in one python3 call (fixes M5: 3x subprocess, echo safety) ---
28
+ # Outputs: TOOL_NAME|DISPLAY_NAME|RESPONSE_TEXT (newlines in response encoded as \n literals)
29
+ parsed="$(printf '%s' "$raw" | python3 - <<'PYEOF'
30
+ import sys, json, re
31
+
32
+ try:
33
+ d = json.load(sys.stdin)
34
+ except Exception:
35
+ print("|||")
36
+ sys.exit(0)
37
+
38
+ tool_name = d.get('tool_name', '')
39
+ prompt = d.get('tool_input', {}).get('prompt', '')
40
+
41
+ # Extract display name — safe alternative to grep -oP (fixes C2: macOS BSD grep no -P)
42
+ display_name = ''
43
+ m = re.search(r'You are ([A-Za-z]+)\s*\(', prompt)
44
+ if m:
45
+ display_name = m.group(1)
46
+
47
+ # Extract response text
48
+ response_text = ''
49
+ for item in d.get('tool_response', {}).get('content', []):
50
+ if item.get('type') == 'text':
51
+ response_text = item['text']
52
+ break
53
+
54
+ # Strip leading icon + bold name header (e.g. "📊 **Mary:** " or garbled prefix)
55
+ response_text = response_text.strip()
56
+ response_text = re.sub(r'^\S*\s*\*\*[^:]+:\*\*\s*', '', response_text).strip()
57
+
58
+ # Encode newlines so we can pass multi-line text through a single shell variable (fixes m3)
59
+ response_text = response_text.replace('\n', '\\n')
60
+
61
+ print(f"{tool_name}|{display_name}|{response_text}")
62
+ PYEOF
63
+ )" 2>/dev/null || true
64
+
65
+ [[ -z "$parsed" ]] && exit 0
66
+
67
+ tool_name="${parsed%%|*}"
68
+ rest="${parsed#*|}"
69
+ display_name="${rest%%|*}"
70
+ response_text="${rest#*|}"
71
+
72
+ # Decode \n back to newlines for TTS
73
+ response_text="${response_text//\\n/ }"
74
+
75
+ # --- Only handle Agent tool ---
76
+ [[ "$tool_name" != "Agent" ]] && exit 0
77
+
78
+ # --- Fingerprint: only fire for BMAD party mode agents (safe string match, no pipe) ---
79
+ [[ "$raw" == *"BMAD agent in a collaborative roundtable"* ]] || exit 0
80
+
81
+ [[ -z "$display_name" ]] && exit 0
82
+ [[ -z "$response_text" ]] && exit 0
83
+
84
+ # --- Resolve project root ---
85
+ project_root="${CLAUDE_PROJECT_DIR:-}"
86
+
87
+ # --- Find bmad-speak.sh (prefer project-local, fall back to global) ---
88
+ bmad_speak=""
89
+ if [[ -n "$project_root" && -f "$project_root/.claude/hooks/bmad-speak.sh" ]]; then
90
+ bmad_speak="$project_root/.claude/hooks/bmad-speak.sh"
91
+ elif [[ -f "$SCRIPT_DIR/bmad-speak.sh" ]]; then
92
+ bmad_speak="$SCRIPT_DIR/bmad-speak.sh"
93
+ fi
94
+ [[ -z "$bmad_speak" ]] && exit 0
95
+
96
+ # --- Look up canonical agent ID from project manifest via python3 (fixes M4: awk CSV comma) ---
97
+ agent_id="$display_name" # fallback
98
+ if [[ -n "$project_root" && -f "$project_root/_bmad/_config/agent-manifest.csv" ]]; then
99
+ manifest="$project_root/_bmad/_config/agent-manifest.csv"
100
+ matched="$(python3 - "$manifest" "$display_name" <<'PYEOF'
101
+ import sys, csv
102
+ manifest_path, target = sys.argv[1], sys.argv[2].lower()
103
+ try:
104
+ with open(manifest_path, newline='', encoding='utf-8') as f:
105
+ for row in csv.DictReader(f):
106
+ if row.get('displayName', '').lower() == target:
107
+ print(row.get('name', ''))
108
+ break
109
+ except Exception:
110
+ pass
111
+ PYEOF
112
+ )" 2>/dev/null || true
113
+ [[ -n "$matched" ]] && agent_id="$matched"
114
+ fi
115
+
116
+ # --- Apply verbosity truncation ---
117
+ verbosity="medium"
118
+ # Guard project_root empty to avoid /.claude/... path (fixes M1)
119
+ if [[ -n "$project_root" && -f "$project_root/.claude/tts-verbosity.txt" ]]; then
120
+ v="$(tr -d '[:space:]' < "$project_root/.claude/tts-verbosity.txt")"
121
+ [[ -n "$v" ]] && verbosity="$v"
122
+ elif [[ -f "$HOME/.claude/tts-verbosity.txt" ]]; then
123
+ v="$(tr -d '[:space:]' < "$HOME/.claude/tts-verbosity.txt")"
124
+ [[ -n "$v" ]] && verbosity="$v"
125
+ fi
126
+
127
+ case "$verbosity" in
128
+ low)
129
+ # First sentence — fall back to full text if no punctuation (fixes m1)
130
+ first="$(printf '%s' "$response_text" | python3 -c "
131
+ import sys, re
132
+ t = sys.stdin.read()
133
+ m = re.match(r'^.*?[.!?]', t)
134
+ print(m.group(0) if m else t)
135
+ " 2>/dev/null || printf '%s' "$response_text")"
136
+ [[ -n "$first" ]] && response_text="$first"
137
+ ;;
138
+ medium)
139
+ # First 2 sentences — fall back to full text if no punctuation (fixes m1)
140
+ two="$(printf '%s' "$response_text" | python3 -c "
141
+ import sys, re
142
+ t = sys.stdin.read()
143
+ parts = re.findall(r'.*?[.!?]', t)
144
+ print(' '.join(parts[:2]) if parts else t)
145
+ " 2>/dev/null || printf '%s' "$response_text")"
146
+ [[ -n "$two" ]] && response_text="$two"
147
+ ;;
148
+ # high = full text
149
+ esac
150
+
151
+ [[ -z "$response_text" ]] && exit 0
152
+
153
+ # --- Acquire queue lock (flock: cross-process, auto-releases on crash) ---
154
+ exec 9>"$LOCK_FILE"
155
+ if command -v flock &>/dev/null; then
156
+ flock -w 60 9
157
+ "$bmad_speak" "$agent_id" "$response_text" || true
158
+ flock -u 9
159
+ else
160
+ # macOS fallback: atomic mkdir polling lock
161
+ LOCK_DIR="/tmp/agentvibes-party-queue.lock.d"
162
+ # Register trap BEFORE acquiring lock so SIGTERM can't orphan it (fixes M3)
163
+ trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT
164
+ WAITED=0
165
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do
166
+ sleep 0.5
167
+ WAITED=$((WAITED + 1))
168
+ if [[ $WAITED -ge 120 ]]; then
169
+ echo "[AgentVibes] Party mode TTS queue timeout for agent: $agent_id" >&2
170
+ exit 0
171
+ fi
172
+ done
173
+ "$bmad_speak" "$agent_id" "$response_text" || true
174
+ rmdir "$LOCK_DIR" 2>/dev/null || true
175
+ fi
@@ -0,0 +1,207 @@
1
+ #
2
+ # File: ~/.claude/hooks-windows/bmad-party-speak.ps1
3
+ #
4
+ # AgentVibes PostToolUse Hook - BMAD Party Mode TTS
5
+ #
6
+ # Fires after every Agent tool call. Detects BMAD party mode agents by
7
+ # fingerprinting the prompt, extracts the agent display name, maps it to
8
+ # the canonical agent ID via the manifest, then calls bmad-speak.ps1.
9
+ #
10
+ # Installed globally so it works in any BMAD project.
11
+ # Uses $env:CLAUDE_PROJECT_DIR to locate the project manifest at runtime.
12
+ #
13
+
14
+ try {
15
+ # --- Read stdin safely ---
16
+ $raw = [Console]::In.ReadToEnd()
17
+ if (-not $raw -or $raw.Trim() -eq "") { exit 0 }
18
+
19
+ $data = $raw | ConvertFrom-Json
20
+ if (-not $data) { exit 0 }
21
+
22
+ # --- Only handle Agent tool ---
23
+ if ($data.tool_name -ne "Agent") { exit 0 }
24
+
25
+
26
+ # --- Extract prompt ---
27
+ $prompt = $data.tool_input.prompt
28
+ if (-not $prompt) { exit 0 }
29
+
30
+ # --- Fingerprint: only fire for BMAD party mode agents ---
31
+ if ($prompt -notmatch "BMAD agent in a collaborative roundtable") { exit 0 }
32
+
33
+ # --- Extract display name from "You are {Name} (" ---
34
+ if ($prompt -notmatch "You are ([A-Za-z]+)\s*\(") { exit 0 }
35
+ $DisplayName = $Matches[1]
36
+
37
+ # --- Extract response text ---
38
+ $content = $data.tool_response.content
39
+ if (-not $content -or $content.Count -eq 0) { exit 0 }
40
+ $ResponseText = ($content | Where-Object { $_.type -eq "text" } | Select-Object -First 1).text
41
+ if (-not $ResponseText) { exit 0 }
42
+
43
+ # Strip leading icon + bold name header e.g. "📊 **Mary:**" or garbled "≡ƒôè **Mary:**"
44
+ # Trim first so leading newlines don't defeat the ^ anchor (fixes M7)
45
+ $ResponseText = $ResponseText.Trim()
46
+ $ResponseText = $ResponseText -replace '^\S*\s*\*\*[^:]+:\*\*\s*', ''
47
+ $ResponseText = $ResponseText.Trim()
48
+ if (-not $ResponseText) { exit 0 }
49
+
50
+ # --- Resolve paths ---
51
+ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
52
+ $ProjectRoot = $env:CLAUDE_PROJECT_DIR
53
+
54
+ # --- Find bmad-speak.ps1 (prefer project-local, fall back to global) ---
55
+ $BmadSpeak = $null
56
+ if ($ProjectRoot) {
57
+ $local = Join-Path $ProjectRoot ".claude\hooks-windows\bmad-speak.ps1"
58
+ if (Test-Path $local) { $BmadSpeak = $local }
59
+ }
60
+ if (-not $BmadSpeak) {
61
+ $global = Join-Path $ScriptDir "bmad-speak.ps1"
62
+ if (Test-Path $global) { $BmadSpeak = $global }
63
+ }
64
+ if (-not $BmadSpeak) { exit 0 }
65
+
66
+ # --- Look up canonical agent ID from project manifest (fixes m2: UTF8 encoding) ---
67
+ $AgentId = $DisplayName # fallback
68
+ if ($ProjectRoot) {
69
+ $ManifestFile = Join-Path $ProjectRoot "_bmad\_config\agent-manifest.csv"
70
+ if (Test-Path $ManifestFile) {
71
+ $rows = Import-Csv $ManifestFile -Encoding UTF8
72
+ foreach ($row in $rows) {
73
+ if ($row.displayName -ieq $DisplayName) {
74
+ $AgentId = $row.name
75
+ break
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ # --- Apply verbosity truncation ---
82
+ $Verbosity = "medium"
83
+ $verbosityPaths = @(
84
+ (Join-Path $env:USERPROFILE ".claude\tts-verbosity.txt")
85
+ )
86
+ if ($ProjectRoot) {
87
+ $verbosityPaths = @((Join-Path $ProjectRoot ".claude\tts-verbosity.txt")) + $verbosityPaths
88
+ }
89
+ foreach ($p in $verbosityPaths) {
90
+ if (Test-Path $p) {
91
+ $v = (Get-Content $p -Raw -ErrorAction SilentlyContinue).Trim()
92
+ if ($v) { $Verbosity = $v; break }
93
+ }
94
+ }
95
+
96
+ switch ($Verbosity) {
97
+ "low" {
98
+ $sentences = [regex]::Split($ResponseText, '(?<=[.!?])\s+')
99
+ # Fall back to full text if no sentence-ending punctuation (fixes m1)
100
+ if ($sentences.Count -gt 0 -and $sentences[0]) { $ResponseText = $sentences[0] }
101
+ }
102
+ "medium" {
103
+ $sentences = [regex]::Split($ResponseText, '(?<=[.!?])\s+')
104
+ # Fall back to full text if no sentence-ending punctuation (fixes m1)
105
+ $truncated = ($sentences | Select-Object -First 2) -join " "
106
+ if ($truncated) { $ResponseText = $truncated }
107
+ }
108
+ # "high" = full text
109
+ }
110
+
111
+ # --- Pre-synthesize WAV before acquiring mutex so synthesis overlaps with previous agent's playback ---
112
+ $PreSynthWav = $null
113
+ try {
114
+ # Resolve agent voice from voice map
115
+ $VoiceMapLocal = if ($ProjectRoot) { Join-Path $ProjectRoot ".agentvibes\bmad-voice-map.json" } else { $null }
116
+ $VoiceMapGlobal = Join-Path $env:USERPROFILE ".agentvibes\bmad-voice-map.json"
117
+ $VoiceMapFile = if ($VoiceMapLocal -and (Test-Path $VoiceMapLocal)) { $VoiceMapLocal }
118
+ elseif (Test-Path $VoiceMapGlobal) { $VoiceMapGlobal }
119
+ else { $null }
120
+
121
+ $AgentVoiceName = $null
122
+ $SpeakerId = $null
123
+ if ($VoiceMapFile) {
124
+ $vm = Get-Content $VoiceMapFile -Raw | ConvertFrom-Json
125
+ $profile = $vm.agents.$AgentId
126
+ if ($profile -and $profile.voice) {
127
+ $raw = $profile.voice
128
+ if ($raw -match '::') {
129
+ $parts = $raw -split '::'
130
+ $AgentVoiceName = $parts[0]
131
+ if ($parts[1] -match '-(\d+)$') { $SpeakerId = $Matches[1] }
132
+ } else {
133
+ $AgentVoiceName = $raw
134
+ }
135
+ }
136
+ }
137
+
138
+ # Locate piper
139
+ $PiperExe = "$env:LOCALAPPDATA\Programs\Piper\piper.exe"
140
+ if (-not (Test-Path $PiperExe)) {
141
+ $found = Get-Command piper.exe -ErrorAction SilentlyContinue
142
+ if ($found) { $PiperExe = $found.Source }
143
+ }
144
+
145
+ if (Test-Path $PiperExe) {
146
+ $VoicesDir = "$env:USERPROFILE\.claude\piper-voices"
147
+ # Fall back to first available voice if agent voice not found
148
+ if (-not $AgentVoiceName) {
149
+ $first = Get-ChildItem $VoicesDir -Filter "*.onnx" -ErrorAction SilentlyContinue | Select-Object -First 1
150
+ if ($first) { $AgentVoiceName = $first.BaseName }
151
+ }
152
+ if ($AgentVoiceName -and ($AgentVoiceName -match '^[a-zA-Z0-9_\-\.]+$')) {
153
+ $VoiceModel = Join-Path $VoicesDir "$AgentVoiceName.onnx"
154
+ if (Test-Path $VoiceModel) {
155
+ $AudioDir = "$env:USERPROFILE\.claude\audio"
156
+ if (-not (Test-Path $AudioDir)) { New-Item -ItemType Directory -Path $AudioDir -Force | Out-Null }
157
+ $PreSynthWav = Join-Path $AudioDir "tts-presynth-$([System.IO.Path]::GetRandomFileName() -replace '\..*').wav"
158
+ $piperArgs = @("--model", $VoiceModel, "--output-file", $PreSynthWav)
159
+ if ($SpeakerId) { $piperArgs += @("--speaker", $SpeakerId) }
160
+ $ResponseText | & $PiperExe @piperArgs 2>$null
161
+ if (-not (Test-Path $PreSynthWav) -or (Get-Item $PreSynthWav).Length -eq 0) {
162
+ $PreSynthWav = $null
163
+ }
164
+ }
165
+ }
166
+ }
167
+ } catch {
168
+ $PreSynthWav = $null # degrade gracefully — will synthesize inside mutex instead
169
+ }
170
+
171
+ # --- Speak with queue serialization (named mutex, cross-process) ---
172
+ $mutex = New-Object System.Threading.Mutex($false, "AgentVibesPartyModeTTSQueue")
173
+ try {
174
+ $acquired = $false
175
+ try {
176
+ # WaitOne throws AbandonedMutexException if prior process crashed while holding it.
177
+ # That exception means we DID acquire the mutex — treat it as success (fixes M2).
178
+ $acquired = $mutex.WaitOne(60000)
179
+ } catch [System.Threading.AbandonedMutexException] {
180
+ $acquired = $true # abandoned = we now own it
181
+ }
182
+
183
+ if ($acquired) {
184
+ try {
185
+ # Pass pre-synthesized WAV path so play-tts.ps1 skips synthesis (reduces gap between agents)
186
+ if ($PreSynthWav) { $env:AGENTVIBES_PRESYNTHESIZED_WAV = $PreSynthWav }
187
+ # Pass positional args directly after -File (spaces handled by quoting via array)
188
+ & powershell -NoProfile -ExecutionPolicy Bypass -File $BmadSpeak $AgentId $ResponseText
189
+ } finally {
190
+ $env:AGENTVIBES_PRESYNTHESIZED_WAV = ""
191
+ if ($PreSynthWav -and (Test-Path $PreSynthWav)) {
192
+ Remove-Item $PreSynthWav -Force -ErrorAction SilentlyContinue
193
+ }
194
+ $mutex.ReleaseMutex()
195
+ }
196
+ } else {
197
+ # Timed out — log to stderr so it's visible in hook error output (fixes M6)
198
+ [Console]::Error.WriteLine("[AgentVibes] Party mode TTS queue timeout for agent: $AgentId")
199
+ }
200
+ } finally {
201
+ $mutex.Close()
202
+ }
203
+
204
+ } catch {
205
+ # Silently exit — never block Claude
206
+ exit 0
207
+ }
@@ -50,6 +50,7 @@ $VoiceMapGlobal = Join-Path $env:USERPROFILE ".agentvibes\bmad-voice-map.json"
50
50
  $VoiceMapFile = if (Test-Path $VoiceMapLocal) { $VoiceMapLocal } else { $VoiceMapGlobal }
51
51
 
52
52
  $AgentVoice = ""
53
+ $AgentPretext = ""
53
54
  $AgentPersonality = ""
54
55
  $AgentBgEnabled = $false
55
56
  $AgentBgTrack = ""
@@ -72,24 +73,35 @@ if (Test-Path $_BgVolFile) {
72
73
  $AgentBgVolume = "0.20"
73
74
  }
74
75
 
75
- if (Test-Path $VoiceMapFile) {
76
- try {
77
- $VoiceMap = Get-Content $VoiceMapFile -Raw | ConvertFrom-Json
76
+ # Resolve agent ID and display name/title from manifest (needed for default pretext)
77
+ $AgentDisplayName = ""
78
+ $AgentTitle = ""
78
79
 
79
- # Resolve agent ID: match canonical ID or display name prefix
80
+ if (Test-Path $ManifestFile) {
81
+ try {
80
82
  $ManifestRows = Import-Csv $ManifestFile -Encoding UTF8
81
83
  foreach ($row in $ManifestRows) {
82
84
  $id = ($row.PSObject.Properties | Select-Object -First 1).Value -replace '^"|"$', ''
83
85
  $display = ($row.PSObject.Properties | Select-Object -Skip 1 -First 1).Value -replace '^"|"$', ''
86
+ $title = ($row.PSObject.Properties | Select-Object -Skip 2 -First 1).Value -replace '^"|"$', ''
84
87
  if ($id -ieq $AgentNameOrId -or $display -like "$AgentNameOrId*") {
85
- $AgentId = $id
88
+ $AgentId = $id
89
+ $AgentDisplayName = $display
90
+ $AgentTitle = $title
86
91
  break
87
92
  }
88
93
  }
94
+ } catch { }
95
+ }
96
+
97
+ if (Test-Path $VoiceMapFile) {
98
+ try {
99
+ $VoiceMap = Get-Content $VoiceMapFile -Raw | ConvertFrom-Json
89
100
 
90
101
  if ($AgentId -and $VoiceMap.agents.$AgentId) {
91
102
  $Profile = $VoiceMap.agents.$AgentId
92
103
  if ($Profile.voice) { $AgentVoice = $Profile.voice }
104
+ if ($Profile.pretext) { $AgentPretext = $Profile.pretext }
93
105
  if ($Profile.personality) { $AgentPersonality = $Profile.personality }
94
106
  if ($Profile.backgroundMusic) {
95
107
  $AgentBgEnabled = [bool]$Profile.backgroundMusic.enabled
@@ -105,6 +117,16 @@ if (Test-Path $VoiceMapFile) {
105
117
  }
106
118
  }
107
119
 
120
+ # Fall back to default pretext if none stored: "DisplayName, Title here."
121
+ # Matches AgentVoiceStore.getDefaultPretext() in agent-voice-store.js
122
+ if (-not $AgentPretext -and $AgentDisplayName) {
123
+ if ($AgentTitle) {
124
+ $AgentPretext = "$AgentDisplayName, $AgentTitle here."
125
+ } else {
126
+ $AgentPretext = "$AgentDisplayName here."
127
+ }
128
+ }
129
+
108
130
  # ---------------------------------------------------------------------------
109
131
  # Locate play-tts.ps1 — prefer project-local, fall back to global
110
132
  $PlayTtsLocal = Join-Path $ProjectRoot ".claude\hooks-windows\play-tts.ps1"
@@ -163,11 +185,14 @@ if ($AgentBgEnabled -and $AgentBgTrack) {
163
185
  }
164
186
 
165
187
  try {
188
+ # Prepend pretext if configured (e.g. "As your UX designer")
189
+ $SpeakText = if ($AgentPretext) { "$AgentPretext. $Dialogue" } else { $Dialogue }
190
+
166
191
  # Speak with agent's voice (or global voice if none configured)
167
192
  if ($AgentVoice) {
168
- & powershell -NoProfile -ExecutionPolicy Bypass -File $PlayTtsScript $Dialogue $AgentVoice
193
+ & powershell -NoProfile -ExecutionPolicy Bypass -File $PlayTtsScript $SpeakText $AgentVoice
169
194
  } else {
170
- & powershell -NoProfile -ExecutionPolicy Bypass -File $PlayTtsScript $Dialogue
195
+ & powershell -NoProfile -ExecutionPolicy Bypass -File $PlayTtsScript $SpeakText
171
196
  }
172
197
  } finally {
173
198
  # Restore personality
@@ -70,15 +70,52 @@ elseif (Test-Path $VoiceFile) {
70
70
  $VoiceName = (Get-Content $VoiceFile -Raw).Trim()
71
71
  }
72
72
 
73
- # Strip display name suffix (e.g. "en_US-libritts-high::Bella-9" -> "en_US-libritts-high")
74
- # and extract speaker ID if present (works for both override and file)
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.
75
77
  if ($VoiceName -match '::') {
76
78
  $parts = $VoiceName -split '::'
77
79
  $VoiceName = $parts[0]
78
- if ($parts.Length -ge 2 -and $parts[1] -match '-(\d+)$') {
79
- $env:PIPER_SPEAKER = $Matches[1]
80
- } else {
81
- Remove-Item env:PIPER_SPEAKER -ErrorAction SilentlyContinue
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
+ }
82
119
  }
83
120
  } else {
84
121
  # No multi-speaker syntax — clear any stale speaker env var
@@ -128,58 +128,85 @@ if ($BgEnabled -or $HasReverb) {
128
128
  }
129
129
  }
130
130
 
131
+ # Check for pre-synthesized WAV (party mode optimization — synthesis done before mutex acquisition)
132
+ $PreSynthWav = $env:AGENTVIBES_PRESYNTHESIZED_WAV
133
+ $UsePreSynth = $PreSynthWav -and (Test-Path $PreSynthWav) -and
134
+ (Get-Item $PreSynthWav -ErrorAction SilentlyContinue).Length -gt 0
135
+
131
136
  # If background music or reverb enabled and ffmpeg available, tell provider to skip playback
132
137
  if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
133
138
  $env:AGENTVIBES_NO_PLAY = "1"
134
139
  }
135
140
 
136
- # Call the provider script
141
+ # Call the provider script (skip if using pre-synthesized audio)
137
142
  # When post-processing (reverb/music), capture output preserving InformationRecord colors.
138
143
  # Otherwise call directly so Write-Host colors pass through to the terminal.
139
144
  $NeedsPostProcess = ($BgEnabled -or $HasReverb) -and $HasFfmpeg
140
- try {
141
- if ($NeedsPostProcess) {
142
- if ($VoiceOverride) {
143
- $providerOutput = & $ProviderScript $Text $VoiceOverride 6>&1 2>&1
144
- } else {
145
- $providerOutput = & $ProviderScript $Text 6>&1 2>&1
145
+ if ($UsePreSynth) {
146
+ Write-Host "[SYNTH] Using pre-synthesized audio..." -ForegroundColor Cyan
147
+ # If no post-processing needed, play the pre-synth file directly and exit
148
+ if (-not $NeedsPostProcess) {
149
+ $player = $null
150
+ try {
151
+ $player = New-Object System.Media.SoundPlayer $PreSynthWav
152
+ $player.PlaySync()
153
+ } catch {
154
+ Write-Host "[WARNING] Pre-synth playback failed: $_" -ForegroundColor Yellow
155
+ } finally {
156
+ if ($player) { $player.Dispose() }
146
157
  }
147
- # Re-emit preserving colors from InformationRecords (Write-Host output)
148
- foreach ($item in $providerOutput) {
149
- if ($item -is [System.Management.Automation.InformationRecord]) {
150
- $msg = $item.MessageData
151
- if ($msg -is [System.Management.Automation.HostInformationMessage]) {
152
- Write-Host $msg.Message -ForegroundColor $msg.ForegroundColor -NoNewline:$msg.NoNewLine
153
- if (-not $msg.NoNewLine) { Write-Host }
158
+ Remove-Item env:AGENTVIBES_NO_PLAY -ErrorAction SilentlyContinue
159
+ exit 0
160
+ }
161
+ } else {
162
+ try {
163
+ if ($NeedsPostProcess) {
164
+ if ($VoiceOverride) {
165
+ $providerOutput = & $ProviderScript $Text $VoiceOverride 6>&1 2>&1
166
+ } else {
167
+ $providerOutput = & $ProviderScript $Text 6>&1 2>&1
168
+ }
169
+ # Re-emit preserving colors from InformationRecords (Write-Host output)
170
+ foreach ($item in $providerOutput) {
171
+ if ($item -is [System.Management.Automation.InformationRecord]) {
172
+ $msg = $item.MessageData
173
+ if ($msg -is [System.Management.Automation.HostInformationMessage]) {
174
+ Write-Host $msg.Message -ForegroundColor $msg.ForegroundColor -NoNewline:$msg.NoNewLine
175
+ if (-not $msg.NoNewLine) { Write-Host }
176
+ } else {
177
+ Write-Host "$item"
178
+ }
154
179
  } else {
155
180
  Write-Host "$item"
156
181
  }
157
- } else {
158
- Write-Host "$item"
159
182
  }
160
- }
161
- } else {
162
- if ($VoiceOverride) {
163
- & $ProviderScript $Text $VoiceOverride
164
183
  } else {
165
- & $ProviderScript $Text
184
+ if ($VoiceOverride) {
185
+ & $ProviderScript $Text $VoiceOverride
186
+ } else {
187
+ & $ProviderScript $Text
188
+ }
166
189
  }
167
190
  }
168
- }
169
- catch {
170
- Write-Host "[ERROR] TTS Error: $_" -ForegroundColor Red
171
- Remove-Item env:AGENTVIBES_NO_PLAY -ErrorAction SilentlyContinue
172
- exit 1
191
+ catch {
192
+ Write-Host "[ERROR] TTS Error: $_" -ForegroundColor Red
193
+ Remove-Item env:AGENTVIBES_NO_PLAY -ErrorAction SilentlyContinue
194
+ exit 1
195
+ }
173
196
  }
174
197
 
175
198
  # Apply reverb and/or mix with background music
176
199
  if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
177
200
  Remove-Item env:AGENTVIBES_NO_PLAY -ErrorAction SilentlyContinue
178
201
 
179
- # Find the most recent TTS wav file
202
+ # Find the WAV to post-process: use pre-synthesized file if available, else most recent
180
203
  $AudioDir = "$ClaudeDir\audio"
181
- $RecentWav = Get-ChildItem -Path $AudioDir -Filter "tts-*.wav" -ErrorAction SilentlyContinue |
182
- Sort-Object LastWriteTime -Descending | Select-Object -First 1
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
209
+ }
183
210
 
184
211
  if ($RecentWav -and $RecentWav.Length -gt 0) {
185
212
  $voicePath = $RecentWav.FullName
@@ -282,7 +309,7 @@ if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
282
309
  $fadeOutStart = $totalDuration - 2
283
310
 
284
311
  # Filter: music fades in 0.5s, voice delayed 2s, music fades out last 2s
285
- $filter = "[0:a]volume=${BgVolume},afade=t=in:d=0.5,afade=t=out:st=${fadeOutStart}:d=2[bg];[1:a]adelay=2000|2000,apad=pad_dur=2[voice];[bg][voice]amix=inputs=2:duration=longest:dropout_transition=2[out]"
312
+ $filter = "[0:a]volume=${BgVolume},afade=t=in:d=0.5,afade=t=out:st=${fadeOutStart}:d=2[bg];[1:a]adelay=1000|1000,volume=1.5,apad=pad_dur=2[voice];[bg][voice]amix=inputs=2:duration=longest:dropout_transition=2:normalize=0[out]"
286
313
 
287
314
  # Run ffmpeg - use Start-Process to avoid stderr issues with $ErrorActionPreference
288
315
  $ffmpegArgs = "-y -stream_loop -1 -i `"$BgTrackPath`" -i `"$voicePath`" -filter_complex `"$filter`" -map `"[out]`" -t $totalDuration `"$MixedFile`""