agentvibes 5.12.0-alpha.0 → 5.12.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.
- package/.agentvibes/config.json +15 -1
- package/.agentvibes/install-manifest.json +342 -0
- package/.claude/activation-instructions +54 -54
- package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
- package/.claude/commands/agent-vibes-rdp.md +24 -24
- package/.claude/config/audio-effects.cfg +5 -5
- package/.claude/config/audio-effects.cfg.bak-kokoro +7 -0
- package/.claude/config/background-music-enabled.txt +1 -1
- package/.claude/config/background-music-position.txt +27 -0
- package/.claude/config/background-music-volume.txt +1 -0
- package/.claude/config/background-music.cfg +1 -0
- package/.claude/config/background-music.txt +1 -0
- package/.claude/config/language.txt +1 -0
- package/.claude/config/personality.txt +1 -0
- package/.claude/config/tts-speech-rate.txt +1 -0
- package/.claude/config/tts-verbosity.txt +1 -0
- package/.claude/docs/TERMUX_SETUP.md +408 -408
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/play-tts-ssh-remote.sh +42 -5
- package/.claude/hooks/play-tts.sh +21 -9
- package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +119 -0
- package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +153 -0
- package/.claude/hooks-windows/tts-watcher.ps1 +55 -0
- package/.claude/piper-voices-dir.txt +1 -0
- package/.clawdbot/README.md +105 -105
- package/.mcp.json +22 -0
- package/LICENSE +190 -190
- package/README.md +11 -6
- package/RELEASE_NOTES.md +38 -0
- package/WINDOWS-SETUP.md +208 -208
- package/bin/agent-vibes +39 -39
- package/bin/mcp-server.js +121 -121
- package/bin/test-bmad-pr +78 -78
- package/mcp-server/QUICK_START.md +203 -203
- package/mcp-server/README.md +345 -345
- package/mcp-server/examples/claude_desktop_config.json +11 -11
- package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
- package/mcp-server/examples/custom_instructions.md +169 -169
- package/mcp-server/pyproject.toml +52 -52
- package/mcp-server/requirements.txt +2 -2
- package/mcp-server/test_server.py +395 -395
- package/package.json +1 -1
- package/src/cli/list-personalities.js +110 -110
- package/src/cli/list-voices.js +114 -114
- package/src/commands/bmad-voices.js +394 -394
- package/src/console/app.js +1 -9
- package/src/console/brand-colors.js +13 -13
- package/src/console/constants/personalities.js +44 -44
- package/src/console/footer-config.js +0 -4
- package/src/console/navigation.js +0 -1
- package/src/console/tabs/agents-tab.js +14 -2
- package/src/console/tabs/help-tab.js +314 -314
- package/src/console/tabs/music-tab.js +101 -2
- package/src/console/tabs/placeholder-tab.js +0 -2
- package/src/console/tabs/readme-tab.js +272 -272
- package/src/console/tabs/settings-tab.js +13 -2
- package/src/console/tabs/setup-tab.js +30 -2
- package/src/console/tabs/voices-tab.js +19 -9
- package/src/console/widgets/destroy-list.js +25 -25
- package/src/console/widgets/notice.js +55 -55
- package/src/installer/language-screen.js +31 -31
- package/src/installer/music-file-input.js +304 -304
- package/src/services/language-service.js +47 -47
- package/src/services/navigation-service.js +1 -1
- package/src/utils/audio-format-validator.js +277 -277
- package/src/utils/dependency-checker.js +469 -469
- package/src/utils/file-ownership-verifier.js +358 -358
- package/src/utils/music-file-validator.js +285 -285
- package/src/utils/preview-list-prompt.js +144 -144
- package/src/utils/secure-music-storage.js +412 -412
- package/voice-assignments.json +8244 -8244
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
20260703
|
|
@@ -34,8 +34,32 @@ fi
|
|
|
34
34
|
# Default to "default" so remote always has an LLM key to look up
|
|
35
35
|
LLM_NAME="${LLM_NAME:-default}"
|
|
36
36
|
|
|
37
|
-
#
|
|
38
|
-
|
|
37
|
+
# Music-only preview mode: AGENTVIBES_MUSIC_ONLY=<track.mp3> tells the receiver to
|
|
38
|
+
# play a standalone background-music track (no speech). Used by the Music tab's
|
|
39
|
+
# remote preview so a track can be auditioned on the receiver. In this mode empty
|
|
40
|
+
# TEXT is allowed and the track name is forwarded as the "music" field with
|
|
41
|
+
# kind=music; the receiver resolves the file from its own ~/.claude/audio/tracks/.
|
|
42
|
+
PAYLOAD_KIND="speak"
|
|
43
|
+
MUSIC_ONLY_TRACK=""
|
|
44
|
+
if [[ -n "${AGENTVIBES_MUSIC_STOP:-}" ]]; then
|
|
45
|
+
# Stop whatever music preview is currently playing on the receiver.
|
|
46
|
+
PAYLOAD_KIND="music-stop"
|
|
47
|
+
elif [[ -n "${AGENTVIBES_MUSIC_ONLY:-}" ]]; then
|
|
48
|
+
# Only a bare .mp3 filename is allowed (no path separators, no leading dash) —
|
|
49
|
+
# the receiver resolves it against its own tracks dir, so reject path-like input.
|
|
50
|
+
if [[ "$AGENTVIBES_MUSIC_ONLY" =~ ^[A-Za-z0-9._][A-Za-z0-9._-]*\.mp3$ ]]; then
|
|
51
|
+
PAYLOAD_KIND="music"
|
|
52
|
+
MUSIC_ONLY_TRACK="$AGENTVIBES_MUSIC_ONLY"
|
|
53
|
+
else
|
|
54
|
+
echo "Invalid music track name: $AGENTVIBES_MUSIC_ONLY" >&2
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# Validate required input (music / music-stop modes carry no speech text)
|
|
60
|
+
if [[ "$PAYLOAD_KIND" == "music" || "$PAYLOAD_KIND" == "music-stop" ]]; then
|
|
61
|
+
TEXT="" # no speech in a music preview / stop
|
|
62
|
+
elif [[ -z "$TEXT" ]]; then
|
|
39
63
|
echo "Usage: $0 <text> [voice] [agent_name]" >&2
|
|
40
64
|
exit 1
|
|
41
65
|
fi
|
|
@@ -46,6 +70,9 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
|
46
70
|
|
|
47
71
|
# Derive project name from directory
|
|
48
72
|
PROJECT_NAME=$(basename "$PROJECT_ROOT")
|
|
73
|
+
# Absolute path, forwarded so the receiver-side avatar can show/learn the real
|
|
74
|
+
# remote folder (mirrors the local forward-to-avatar.sh's projectPath field).
|
|
75
|
+
PROJECT_PATH="$PROJECT_ROOT"
|
|
49
76
|
|
|
50
77
|
# ---------------------------------------------------------------------------
|
|
51
78
|
# Get SSH connection details from config
|
|
@@ -365,24 +392,34 @@ build_json_payload() {
|
|
|
365
392
|
--arg music "$BG_FILE" \
|
|
366
393
|
--arg volume "$BG_VOLUME" \
|
|
367
394
|
--arg project "$PROJECT_NAME" \
|
|
395
|
+
--arg projectPath "$PROJECT_PATH" \
|
|
368
396
|
--arg pretext "$PRETEXT" \
|
|
369
397
|
--arg speed "$SPEED" \
|
|
370
398
|
--arg provider "$PROVIDER" \
|
|
371
399
|
--arg llm "$LLM_NAME" \
|
|
372
400
|
--arg mute "$MUTE" \
|
|
373
401
|
--arg language "$LANGUAGE" \
|
|
374
|
-
|
|
402
|
+
--arg kind "$PAYLOAD_KIND" \
|
|
403
|
+
'{text: $text, voice: $voice, effects: $effects, music: $music, volume: $volume, project: $project, projectPath: $projectPath, pretext: $pretext, speed: $speed, provider: $provider, llm: $llm, mute: $mute, language: $language, kind: $kind}'
|
|
375
404
|
else
|
|
376
405
|
# Manual JSON — escape backslashes, quotes, control chars
|
|
377
406
|
local escaped_text
|
|
378
407
|
escaped_text=$(printf '%s' "$TEXT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | tr '\n' ' ' | sed 's/\r//g')
|
|
379
408
|
local escaped_pretext
|
|
380
409
|
escaped_pretext=$(printf '%s' "$PRETEXT" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
|
381
|
-
|
|
382
|
-
|
|
410
|
+
local escaped_project_path
|
|
411
|
+
escaped_project_path=$(printf '%s' "$PROJECT_PATH" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
|
412
|
+
printf '{"text":"%s","voice":"%s","effects":"%s","music":"%s","volume":"%s","project":"%s","projectPath":"%s","pretext":"%s","speed":"%s","provider":"%s","llm":"%s","mute":"%s","language":"%s","kind":"%s"}' \
|
|
413
|
+
"$escaped_text" "$VOICE" "$SOX_EFFECTS" "$BG_FILE" "$BG_VOLUME" "$PROJECT_NAME" "$escaped_project_path" "$escaped_pretext" "$SPEED" "$PROVIDER" "$LLM_NAME" "$MUTE" "$LANGUAGE" "$PAYLOAD_KIND"
|
|
383
414
|
fi
|
|
384
415
|
}
|
|
385
416
|
|
|
417
|
+
# Music-only preview: force the track as the "music" field (overriding any
|
|
418
|
+
# per-LLM/agent background-music config) so the receiver plays exactly this track.
|
|
419
|
+
if [[ "$PAYLOAD_KIND" == "music" ]]; then
|
|
420
|
+
BG_FILE="$MUSIC_ONLY_TRACK"
|
|
421
|
+
fi
|
|
422
|
+
|
|
386
423
|
JSON_PAYLOAD=$(build_json_payload)
|
|
387
424
|
|
|
388
425
|
# SECURITY: Base64-encode entire payload — safe for SSH transport
|
|
@@ -329,7 +329,12 @@ case "$ACTIVE_PROVIDER" in
|
|
|
329
329
|
# Transport — keep it. The receiver's audio-effects.cfg picks the engine.
|
|
330
330
|
;;
|
|
331
331
|
*)
|
|
332
|
-
if [[
|
|
332
|
+
if [[ "$VOICE_OVERRIDE" =~ ^(af|am|bf|bm|jf|jm|kf|km|zf|zm|ff|fm|hf|hm|if|im|pf|pm|ef|em|nf|nm)_[a-zA-Z0-9]+$ ]]; then
|
|
333
|
+
# Kokoro-shaped voice override (e.g. af_heart) must win over the LLM row's
|
|
334
|
+
# engine column, else it is synthesized as a Piper voice, 404s downloading
|
|
335
|
+
# a model that doesn't exist, and stays silent. (Grafted from master WIP.)
|
|
336
|
+
ACTIVE_PROVIDER="kokoro"
|
|
337
|
+
elif [[ -n "$_LLM_ENGINE" ]]; then
|
|
333
338
|
ACTIVE_PROVIDER="$_LLM_ENGINE"
|
|
334
339
|
fi
|
|
335
340
|
;;
|
|
@@ -513,12 +518,17 @@ speak_text() {
|
|
|
513
518
|
termux-ssh)
|
|
514
519
|
bash "$SCRIPT_DIR/play-tts-termux-ssh.sh" "$text" "$voice"
|
|
515
520
|
;;
|
|
516
|
-
ssh-remote)
|
|
521
|
+
ssh-remote|agentvibes-receiver)
|
|
522
|
+
# Both route through the base64-JSON sender that the modern receiver
|
|
523
|
+
# (templates/agentvibes-receiver.ps1 / .sh, installed as play-remote.*)
|
|
524
|
+
# understands. The old voiceless-connections sender spoke a 3-positional-arg
|
|
525
|
+
# protocol for a legacy ~/.agentvibes/play-remote.sh receiver that no current
|
|
526
|
+
# receiver install produces, so it delivered a non-base64 command the modern
|
|
527
|
+
# receiver rejects ("Payload must be base64-encoded"). play-tts-ssh-remote.sh
|
|
528
|
+
# resolves the host from any transport-config mode=remote entry (Priority 2b),
|
|
529
|
+
# so the agentvibes-receiver section is found without an ssh-remote section.
|
|
517
530
|
bash "$SCRIPT_DIR/play-tts-ssh-remote.sh" "$text" "$voice" "" "${profile_file:-}"
|
|
518
531
|
;;
|
|
519
|
-
agentvibes-receiver)
|
|
520
|
-
bash "$SCRIPT_DIR/play-tts-agentvibes-receiver-for-voiceless-connections.sh" "$text" "$voice"
|
|
521
|
-
;;
|
|
522
532
|
*)
|
|
523
533
|
echo "❌ Unknown provider: $provider" >&2
|
|
524
534
|
return 1
|
|
@@ -653,12 +663,14 @@ case "$ACTIVE_PROVIDER" in
|
|
|
653
663
|
termux-ssh)
|
|
654
664
|
exec bash "$SCRIPT_DIR/play-tts-termux-ssh.sh" "$TEXT" "$VOICE_OVERRIDE"
|
|
655
665
|
;;
|
|
656
|
-
ssh-remote)
|
|
666
|
+
ssh-remote|agentvibes-receiver)
|
|
667
|
+
# Both route through the base64-JSON sender the modern receiver understands.
|
|
668
|
+
# The legacy voiceless-connections sender spoke a 3-arg protocol for an old
|
|
669
|
+
# ~/.agentvibes/play-remote.sh receiver that no current install produces, so it
|
|
670
|
+
# delivered a non-base64 command the modern receiver rejects with
|
|
671
|
+
# "Payload must be base64-encoded".
|
|
657
672
|
exec bash "$SCRIPT_DIR/play-tts-ssh-remote.sh" "$TEXT" "$VOICE_OVERRIDE" "" "${AGENT_PROFILE_FILE:-}"
|
|
658
673
|
;;
|
|
659
|
-
agentvibes-receiver)
|
|
660
|
-
exec bash "$SCRIPT_DIR/play-tts-agentvibes-receiver-for-voiceless-connections.sh" "$TEXT" "$VOICE_OVERRIDE"
|
|
661
|
-
;;
|
|
662
674
|
*)
|
|
663
675
|
echo "❌ Unknown provider: $ACTIVE_PROVIDER" >&2
|
|
664
676
|
echo " Run: /agent-vibes:provider list" >&2
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#
|
|
2
|
+
# File: .claude/hooks-windows/audio-cache-utils.ps1
|
|
3
|
+
#
|
|
4
|
+
# AgentVibes Audio Cache Utilities for Windows
|
|
5
|
+
#
|
|
6
|
+
|
|
7
|
+
param(
|
|
8
|
+
[Parameter(Position = 0)]
|
|
9
|
+
[ValidateSet('cleanup', 'stats', 'clear')]
|
|
10
|
+
[string]$Command
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Detect project-local audio dir (same logic as TTS scripts)
|
|
14
|
+
$ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
15
|
+
$ProjectClaudeDir = Join-Path (Split-Path -Parent (Split-Path -Parent $ScriptPath)) ".claude"
|
|
16
|
+
if (Test-Path $ProjectClaudeDir) {
|
|
17
|
+
$AudioDir = "$ProjectClaudeDir\audio"
|
|
18
|
+
} else {
|
|
19
|
+
$AudioDir = "$env:USERPROFILE\.claude\audio"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Ensure-AudioDir {
|
|
23
|
+
if (-not (Test-Path $AudioDir)) {
|
|
24
|
+
New-Item -ItemType Directory -Path $AudioDir -Force | Out-Null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Get-AudioCacheSize {
|
|
29
|
+
Ensure-AudioDir
|
|
30
|
+
|
|
31
|
+
if (-not (Test-Path $AudioDir)) {
|
|
32
|
+
return 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
$files = Get-ChildItem -Path $AudioDir -Filter "*.wav" -ErrorAction SilentlyContinue
|
|
36
|
+
$totalSize = 0
|
|
37
|
+
|
|
38
|
+
foreach ($file in $files) {
|
|
39
|
+
$totalSize += $file.Length
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return $totalSize
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Format-FileSize {
|
|
46
|
+
param([long]$Size)
|
|
47
|
+
|
|
48
|
+
if ($Size -lt 1KB) { return "$Size B" }
|
|
49
|
+
if ($Size -lt 1MB) { return "{0:N2} KB" -f ($Size / 1KB) }
|
|
50
|
+
if ($Size -lt 1GB) { return "{0:N2} MB" -f ($Size / 1MB) }
|
|
51
|
+
return "{0:N2} GB" -f ($Size / 1GB)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Get-CacheStats {
|
|
55
|
+
Ensure-AudioDir
|
|
56
|
+
|
|
57
|
+
$files = Get-ChildItem -Path $AudioDir -Filter "*.wav" -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum
|
|
58
|
+
|
|
59
|
+
$count = if ($files.Count -eq $null) { 0 } else { $files.Count }
|
|
60
|
+
$totalSize = if ($files.Sum -eq $null) { 0 } else { $files.Sum }
|
|
61
|
+
|
|
62
|
+
return @{
|
|
63
|
+
FileCount = $count
|
|
64
|
+
TotalSize = $totalSize
|
|
65
|
+
FormattedSize = Format-FileSize $totalSize
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function Clear-Cache {
|
|
70
|
+
Ensure-AudioDir
|
|
71
|
+
|
|
72
|
+
$files = Get-ChildItem -Path $AudioDir -Filter "*.wav" -ErrorAction SilentlyContinue
|
|
73
|
+
|
|
74
|
+
if ($files.Count -eq 0) {
|
|
75
|
+
Write-Host "[OK] Cache already empty" -ForegroundColor Green
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
$stats = Get-CacheStats
|
|
80
|
+
Write-Host "[CLEANUP] Clearing $($stats.FileCount) audio files ($($stats.FormattedSize))" -ForegroundColor Yellow
|
|
81
|
+
|
|
82
|
+
foreach ($file in $files) {
|
|
83
|
+
Remove-Item $file.FullName -Force -ErrorAction SilentlyContinue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
Write-Host "[OK] Cache cleared" -ForegroundColor Green
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function Show-CacheStats {
|
|
90
|
+
Ensure-AudioDir
|
|
91
|
+
|
|
92
|
+
$stats = Get-CacheStats
|
|
93
|
+
|
|
94
|
+
Write-Host ""
|
|
95
|
+
Write-Host "[STATS] Audio Cache Statistics" -ForegroundColor Cyan
|
|
96
|
+
Write-Host " Location: $AudioDir"
|
|
97
|
+
Write-Host " Files: $($stats.FileCount)"
|
|
98
|
+
Write-Host " Total Size: $($stats.FormattedSize)"
|
|
99
|
+
Write-Host ""
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Main command routing
|
|
103
|
+
switch ($Command) {
|
|
104
|
+
'stats' {
|
|
105
|
+
Show-CacheStats
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
'cleanup' {
|
|
109
|
+
Clear-Cache
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
'clear' {
|
|
113
|
+
Clear-Cache
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
default {
|
|
117
|
+
Show-CacheStats
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# File: .claude/hooks-windows/soprano-gradio-synth.py
|
|
4
|
+
#
|
|
5
|
+
# AgentVibes - Finally, your AI Agents can Talk Back!
|
|
6
|
+
# Website: https://agentvibes.org
|
|
7
|
+
# Repository: https://github.com/paulpreibisch/AgentVibes
|
|
8
|
+
#
|
|
9
|
+
# Co-created by Paul Preibisch with Claude AI
|
|
10
|
+
# Copyright (c) 2025 Paul Preibisch
|
|
11
|
+
#
|
|
12
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
13
|
+
# you may not use this file except in compliance with the License.
|
|
14
|
+
# You may obtain a copy of the License at
|
|
15
|
+
#
|
|
16
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
17
|
+
#
|
|
18
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
19
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
20
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
21
|
+
# See the License for the specific language governing permissions and
|
|
22
|
+
# limitations under the License.
|
|
23
|
+
#
|
|
24
|
+
# ---
|
|
25
|
+
#
|
|
26
|
+
# Soprano Gradio WebUI synthesizer helper.
|
|
27
|
+
# Calls the Soprano WebUI's Gradio API and saves the result as a WAV file.
|
|
28
|
+
# Uses only Python stdlib (json, sys, urllib) — no extra dependencies.
|
|
29
|
+
#
|
|
30
|
+
# Usage: python3 soprano-gradio-synth.py "text to speak" output.wav [port]
|
|
31
|
+
#
|
|
32
|
+
"""
|
|
33
|
+
Soprano Gradio WebUI synthesizer helper for AgentVibes.
|
|
34
|
+
|
|
35
|
+
Calls the Soprano WebUI's Gradio API via the Server-Sent Events (SSE) protocol
|
|
36
|
+
and downloads the generated audio as a WAV file.
|
|
37
|
+
|
|
38
|
+
Flow:
|
|
39
|
+
1. Submit generation request -> get event_id
|
|
40
|
+
2. Poll SSE stream for audio file URL
|
|
41
|
+
3. Download WAV file to output path
|
|
42
|
+
|
|
43
|
+
See: https://github.com/ekwek1/soprano
|
|
44
|
+
"""
|
|
45
|
+
import json
|
|
46
|
+
import sys
|
|
47
|
+
import urllib.request
|
|
48
|
+
import urllib.error
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def synth(text: str, output_path: str, port: int = 7860) -> None:
|
|
52
|
+
base = f"http://127.0.0.1:{port}"
|
|
53
|
+
|
|
54
|
+
# Step 1: Submit generation request
|
|
55
|
+
# Args: text, temperature, top_p, repetition_penalty, chunk_size, streaming
|
|
56
|
+
payload = json.dumps({
|
|
57
|
+
"data": [text, 0.0, 0.95, 1.2, 1, False]
|
|
58
|
+
}).encode()
|
|
59
|
+
|
|
60
|
+
event_id = submit_request(base, payload)
|
|
61
|
+
|
|
62
|
+
# Step 2: Poll SSE stream for audio file URL
|
|
63
|
+
audio_url = poll_for_result(base, event_id)
|
|
64
|
+
|
|
65
|
+
# Step 3: Download the audio file (validate localhost to prevent SSRF)
|
|
66
|
+
download_file(audio_url, output_path, port)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def submit_request(base: str, payload: bytes) -> str:
|
|
70
|
+
"""Submit generation request to Gradio API, return event_id."""
|
|
71
|
+
for api_base in ["/gradio_api/call", "/call"]:
|
|
72
|
+
url = f"{base}{api_base}/generate_speech"
|
|
73
|
+
req = urllib.request.Request(
|
|
74
|
+
url,
|
|
75
|
+
data=payload,
|
|
76
|
+
headers={"Content-Type": "application/json"},
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
80
|
+
return json.loads(resp.read())["event_id"]
|
|
81
|
+
except urllib.error.URLError:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
raise RuntimeError("Could not reach Soprano WebUI API")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def poll_for_result(base: str, event_id: str) -> str:
|
|
88
|
+
"""Poll SSE endpoint until audio file URL is returned."""
|
|
89
|
+
for api_base in ["/gradio_api/call", "/call"]:
|
|
90
|
+
url = f"{base}{api_base}/generate_speech/{event_id}"
|
|
91
|
+
req = urllib.request.Request(url)
|
|
92
|
+
try:
|
|
93
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
94
|
+
for raw_line in resp:
|
|
95
|
+
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
96
|
+
if not line.startswith("data: "):
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
parsed = json.loads(line[6:])
|
|
100
|
+
except json.JSONDecodeError:
|
|
101
|
+
continue
|
|
102
|
+
# Response format: [{"path": "...", "url": "...", ...}, "status string"]
|
|
103
|
+
if isinstance(parsed, list) and len(parsed) >= 1:
|
|
104
|
+
audio = parsed[0]
|
|
105
|
+
if isinstance(audio, dict) and "url" in audio:
|
|
106
|
+
return audio["url"]
|
|
107
|
+
break
|
|
108
|
+
except urllib.error.URLError:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
raise RuntimeError("No audio URL in Soprano response")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def download_file(url: str, output_path: str, port: int = 7860) -> None:
|
|
115
|
+
"""Download audio file from Gradio file server.
|
|
116
|
+
|
|
117
|
+
Security: Validates URL points to localhost to prevent SSRF.
|
|
118
|
+
"""
|
|
119
|
+
# Validate URL is localhost to prevent SSRF via crafted Gradio responses
|
|
120
|
+
allowed_prefixes = [
|
|
121
|
+
f"http://127.0.0.1:{port}/",
|
|
122
|
+
f"http://localhost:{port}/",
|
|
123
|
+
]
|
|
124
|
+
if not any(url.startswith(prefix) for prefix in allowed_prefixes):
|
|
125
|
+
raise RuntimeError(f"Refused download from non-localhost URL: {url[:80]}")
|
|
126
|
+
|
|
127
|
+
req = urllib.request.Request(url)
|
|
128
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
129
|
+
with open(output_path, "wb") as f:
|
|
130
|
+
while True:
|
|
131
|
+
chunk = resp.read(8192)
|
|
132
|
+
if not chunk:
|
|
133
|
+
break
|
|
134
|
+
f.write(chunk)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
if len(sys.argv) < 3:
|
|
139
|
+
print(f"Usage: {sys.argv[0]} \"text\" output.wav [port]", file=sys.stderr)
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
|
|
142
|
+
text = sys.argv[1]
|
|
143
|
+
output = sys.argv[2]
|
|
144
|
+
port = int(sys.argv[3]) if len(sys.argv) > 3 else 7860
|
|
145
|
+
if not (1 <= port <= 65535):
|
|
146
|
+
print(f"Error: Invalid port number: {port}", file=sys.stderr)
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
synth(text, output, port)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
153
|
+
sys.exit(1)
|
|
@@ -31,10 +31,65 @@ try {
|
|
|
31
31
|
try { Rename-Item $f.FullName $procFile -ErrorAction Stop } catch { continue }
|
|
32
32
|
try {
|
|
33
33
|
$req = Get-Content $procFile -Raw | ConvertFrom-Json
|
|
34
|
+
|
|
35
|
+
# Music preview: play/stop a standalone background-music track
|
|
36
|
+
# ASYNCHRONOUSLY (Start-Process) so it never blocks the TTS queue,
|
|
37
|
+
# tracking the ffplay PID so a new request (or an explicit stop)
|
|
38
|
+
# replaces the previous track instead of stacking playback.
|
|
39
|
+
# kind=music → stop any current preview, then play the track
|
|
40
|
+
# kind=music-stop → stop any current preview (no new playback)
|
|
41
|
+
if ($req.kind -eq 'music' -or $req.kind -eq 'music-stop') {
|
|
42
|
+
$pidFile = Join-Path $env:USERPROFILE '.agentvibes\music-preview.pid'
|
|
43
|
+
# Stop the currently-playing preview, if any. Verify the PID is
|
|
44
|
+
# actually ffplay so a recycled PID can't kill an unrelated proc.
|
|
45
|
+
if (Test-Path $pidFile) {
|
|
46
|
+
$oldPid = (Get-Content $pidFile -Raw -ErrorAction SilentlyContinue).Trim()
|
|
47
|
+
if ($oldPid -match '^\d+$') {
|
|
48
|
+
$op = Get-Process -Id ([int]$oldPid) -ErrorAction SilentlyContinue
|
|
49
|
+
if ($op -and $op.ProcessName -eq 'ffplay') { Stop-Process -Id ([int]$oldPid) -Force -ErrorAction SilentlyContinue }
|
|
50
|
+
}
|
|
51
|
+
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
|
|
52
|
+
}
|
|
53
|
+
if ($req.kind -eq 'music') {
|
|
54
|
+
$trackName = [string]$req.music
|
|
55
|
+
if ($trackName -match '^[A-Za-z0-9._][A-Za-z0-9._\-]*\.mp3$') {
|
|
56
|
+
$tracksDir = Join-Path $env:USERPROFILE '.claude\audio\tracks'
|
|
57
|
+
$full = [System.IO.Path]::GetFullPath((Join-Path $tracksDir $trackName))
|
|
58
|
+
$baseFull = [System.IO.Path]::GetFullPath($tracksDir)
|
|
59
|
+
if (-not $baseFull.EndsWith([IO.Path]::DirectorySeparatorChar)) { $baseFull += [IO.Path]::DirectorySeparatorChar }
|
|
60
|
+
if ($full.StartsWith($baseFull, [StringComparison]::OrdinalIgnoreCase) -and (Test-Path $full)) {
|
|
61
|
+
$ffplay = Get-Command ffplay -ErrorAction SilentlyContinue
|
|
62
|
+
if ($ffplay) {
|
|
63
|
+
Write-WatcherLog "INFO" "music-preview id=$($req.id) track=$trackName"
|
|
64
|
+
$mp = Start-Process -FilePath $ffplay.Source -ArgumentList @('-autoexit','-nodisp','-loglevel','quiet','-volume','80',$full) -WindowStyle Hidden -PassThru
|
|
65
|
+
if ($mp) { Set-Content -Path $pidFile -Value $mp.Id -NoNewline -ErrorAction SilentlyContinue }
|
|
66
|
+
} else {
|
|
67
|
+
Write-WatcherLog "WARN" "ffplay not found - cannot preview music id=$($req.id)"
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
Write-WatcherLog "WARN" "music track not found id=$($req.id) track=$trackName"
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
Write-WatcherLog "WARN" "invalid music track name id=$($req.id)"
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
Write-WatcherLog "INFO" "music-stop id=$($req.id)"
|
|
77
|
+
}
|
|
78
|
+
Remove-Item $procFile -Force -ErrorAction SilentlyContinue
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
34
82
|
# Validate voice before passing to command line
|
|
35
83
|
$safeVoice = if ($req.voice -and $req.voice -match '^[a-zA-Z0-9_\-\. :]+$') { $req.voice } else { "" }
|
|
36
84
|
$env:CLAUDE_PROJECT_DIR = $env:USERPROFILE
|
|
37
85
|
$env:AGENTVIBES_NO_PRETEXT = "1"
|
|
86
|
+
# Carry the sender's project (folder name/path) through to the
|
|
87
|
+
# TalkingHead forward so the avatar badge/tab shows the real remote
|
|
88
|
+
# origin instead of this machine's own profile dir. (Grafted from master WIP.)
|
|
89
|
+
if ($req.project) { $env:AGENTVIBES_PROJECT = $req.project }
|
|
90
|
+
else { [System.Environment]::SetEnvironmentVariable("AGENTVIBES_PROJECT", $null, "Process") }
|
|
91
|
+
if ($req.projectPath) { $env:AGENTVIBES_PROJECT_PATH = $req.projectPath }
|
|
92
|
+
else { [System.Environment]::SetEnvironmentVariable("AGENTVIBES_PROJECT_PATH", $null, "Process") }
|
|
38
93
|
# Use SetEnvironmentVariable to truly unset (assignment to $null leaves empty string)
|
|
39
94
|
if ($req.music) { $env:AGENTVIBES_OVERRIDE_MUSIC = $req.music }
|
|
40
95
|
else { [System.Environment]::SetEnvironmentVariable("AGENTVIBES_OVERRIDE_MUSIC", $null, "Process") }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
C:\Users\Paul\.claude\piper-voices
|