294 lines
11 KiB
Bash
Executable File
294 lines
11 KiB
Bash
Executable File
#!/bin/bash
|
|
#
|
|
# File: .claude/hooks/piper-voice-manager.sh
|
|
#
|
|
# AgentVibes - Finally, your AI Agents can Talk Back! Text-to-Speech WITH personality for AI Assistants!
|
|
# Website: https://agentvibes.org
|
|
# Repository: https://github.com/paulpreibisch/AgentVibes
|
|
#
|
|
# Co-created by Paul Preibisch with Claude AI
|
|
# Copyright (c) 2025 Paul Preibisch
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
#
|
|
# DISCLAIMER: This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
# express or implied, including but not limited to the warranties of
|
|
# merchantability, fitness for a particular purpose and noninfringement.
|
|
# In no event shall the authors or copyright holders be liable for any claim,
|
|
# damages or other liability, whether in an action of contract, tort or
|
|
# otherwise, arising from, out of or in connection with the software or the
|
|
# use or other dealings in the software.
|
|
#
|
|
# ---
|
|
#
|
|
# @fileoverview Piper Voice Model Management - Downloads, caches, and validates Piper ONNX voice models
|
|
# @context Voice model lifecycle management for free offline Piper TTS provider
|
|
# @architecture HuggingFace repository integration with local caching, global storage for voice models
|
|
# @dependencies curl (downloads), piper binary (TTS synthesis)
|
|
# @entrypoints Sourced by play-tts-piper.sh, piper-download-voices.sh, and provider management commands
|
|
# @patterns HuggingFace model repository integration, file-based caching (~25MB per voice), global storage
|
|
# @related play-tts-piper.sh, piper-download-voices.sh, provider-manager.sh, GitHub Issue #25
|
|
#
|
|
|
|
# Base URL for Piper voice models on HuggingFace
|
|
PIPER_VOICES_BASE_URL="https://huggingface.co/rhasspy/piper-voices/resolve/main"
|
|
|
|
# AI NOTE: Voice storage precedence order:
|
|
# 1. PIPER_VOICES_DIR environment variable (highest priority)
|
|
# 2. Project-local .claude/piper-voices-dir.txt
|
|
# 3. Directory tree search for .claude/piper-voices-dir.txt
|
|
# 4. Global ~/.claude/piper-voices-dir.txt
|
|
# 5. Default ~/.claude/piper-voices (fallback)
|
|
# This allows per-project voice isolation while defaulting to shared global storage.
|
|
|
|
# @function get_voice_storage_dir
|
|
# @intent Determine directory for storing Piper voice models with precedence chain
|
|
# @why Voice models are large (~25MB each) and should be shared globally by default, but allow per-project overrides
|
|
# @param None
|
|
# @returns Echoes path to voice storage directory
|
|
# @exitcode Always 0
|
|
# @sideeffects Creates directory if it doesn't exist
|
|
# @edgecases Searches up directory tree for .claude/ folder, supports custom paths via env var or config files
|
|
# @calledby All voice management functions (verify_voice, get_voice_path, download_voice, list_downloaded_voices)
|
|
# @calls mkdir, cat, dirname
|
|
get_voice_storage_dir() {
|
|
local voice_dir
|
|
|
|
# Check for custom path in environment or config file
|
|
if [[ -n "$PIPER_VOICES_DIR" ]]; then
|
|
voice_dir="$PIPER_VOICES_DIR"
|
|
else
|
|
# Check for config file (project-local first, then global)
|
|
local config_file
|
|
if [[ -n "$CLAUDE_PROJECT_DIR" ]] && [[ -f "$CLAUDE_PROJECT_DIR/.claude/piper-voices-dir.txt" ]]; then
|
|
config_file="$CLAUDE_PROJECT_DIR/.claude/piper-voices-dir.txt"
|
|
else
|
|
# Search up directory tree for .claude/
|
|
local current_dir="$PWD"
|
|
while [[ "$current_dir" != "/" ]]; do
|
|
if [[ -f "$current_dir/.claude/piper-voices-dir.txt" ]]; then
|
|
config_file="$current_dir/.claude/piper-voices-dir.txt"
|
|
break
|
|
fi
|
|
current_dir=$(dirname "$current_dir")
|
|
done
|
|
|
|
# Check global config
|
|
if [[ -z "$config_file" ]] && [[ -f "$HOME/.claude/piper-voices-dir.txt" ]]; then
|
|
config_file="$HOME/.claude/piper-voices-dir.txt"
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "$config_file" ]]; then
|
|
voice_dir=$(cat "$config_file" | tr -d '[:space:]')
|
|
fi
|
|
fi
|
|
|
|
# Fallback to default global storage
|
|
if [[ -z "$voice_dir" ]]; then
|
|
voice_dir="$HOME/.claude/piper-voices"
|
|
fi
|
|
|
|
mkdir -p "$voice_dir"
|
|
echo "$voice_dir"
|
|
}
|
|
|
|
# @function verify_voice
|
|
# @intent Check if voice model files exist locally (both .onnx and .onnx.json)
|
|
# @why Avoid redundant downloads, detect missing models, ensure model integrity
|
|
# @param $1 {string} voice_name - Voice model name (e.g., en_US-lessac-medium)
|
|
# @returns None
|
|
# @exitcode 0=voice exists and complete, 1=voice missing or incomplete
|
|
# @sideeffects None (read-only check)
|
|
# @edgecases Requires both ONNX model and JSON config to return success
|
|
# @calledby download_voice, piper-download-voices.sh
|
|
# @calls get_voice_storage_dir
|
|
verify_voice() {
|
|
local voice_name="$1"
|
|
local voice_dir
|
|
voice_dir=$(get_voice_storage_dir)
|
|
|
|
local onnx_file="$voice_dir/${voice_name}.onnx"
|
|
local json_file="$voice_dir/${voice_name}.onnx.json"
|
|
|
|
[[ -f "$onnx_file" ]] && [[ -f "$json_file" ]]
|
|
}
|
|
|
|
# @function get_voice_path
|
|
# @intent Get absolute path to voice model ONNX file for Piper binary
|
|
# @why Piper binary requires full absolute path to model file, not just voice name
|
|
# @param $1 {string} voice_name - Voice model name
|
|
# @returns Echoes absolute path to .onnx file to stdout
|
|
# @exitcode 0=success, 1=voice not found
|
|
# @sideeffects Writes error message to stderr if voice not found
|
|
# @edgecases Returns error if voice not downloaded yet
|
|
# @calledby play-tts-piper.sh for TTS synthesis
|
|
# @calls get_voice_storage_dir
|
|
get_voice_path() {
|
|
local voice_name="$1"
|
|
local voice_dir
|
|
voice_dir=$(get_voice_storage_dir)
|
|
|
|
local onnx_file="$voice_dir/${voice_name}.onnx"
|
|
|
|
if [[ ! -f "$onnx_file" ]]; then
|
|
echo "❌ Voice model not found: $voice_name" >&2
|
|
return 1
|
|
fi
|
|
|
|
echo "$onnx_file"
|
|
}
|
|
|
|
# AI NOTE: Voice name format is: lang_LOCALE-speaker-quality
|
|
# Example: en_US-lessac-medium
|
|
# - lang: en (language code)
|
|
# - LOCALE: US (locale/country code)
|
|
# - speaker: lessac (speaker/voice name)
|
|
# - quality: medium (model quality: low/medium/high)
|
|
# HuggingFace repository structure: {lang}/{lang}_{LOCALE}/{speaker}/{quality}/
|
|
|
|
# @function parse_voice_components
|
|
# @intent Extract language, locale, speaker, quality components from voice name
|
|
# @why HuggingFace uses structured directory paths based on these components
|
|
# @param $1 {string} voice_name - Voice name (e.g., en_US-lessac-medium)
|
|
# @returns None (sets global variables)
|
|
# @exitcode Always 0
|
|
# @sideeffects Sets global variables: LANG, LOCALE, SPEAKER, QUALITY
|
|
# @edgecases Expects specific format: lang_LOCALE-speaker-quality
|
|
# @calledby download_voice
|
|
# @calls None (pure string manipulation)
|
|
parse_voice_components() {
|
|
local voice_name="$1"
|
|
|
|
# Extract components from voice name
|
|
# Format: en_US-lessac-medium
|
|
# lang_LOCALE-speaker-quality
|
|
|
|
local lang_locale="${voice_name%%-*}" # en_US
|
|
local speaker_quality="${voice_name#*-}" # lessac-medium
|
|
|
|
LANG="${lang_locale%%_*}" # en
|
|
LOCALE="${lang_locale#*_}" # US
|
|
SPEAKER="${speaker_quality%%-*}" # lessac
|
|
QUALITY="${speaker_quality#*-}" # medium
|
|
}
|
|
|
|
# @function download_voice
|
|
# @intent Download Piper voice model from HuggingFace repository
|
|
# @why Provide free offline TTS voices without requiring API keys
|
|
# @param $1 {string} voice_name - Voice model name (e.g., en_US-lessac-medium)
|
|
# @param $2 {string} lang_code - Language code (optional, inferred from voice_name, unused)
|
|
# @returns None
|
|
# @exitcode 0=success (already downloaded or newly downloaded), 1=download failed
|
|
# @sideeffects Downloads .onnx and .onnx.json files (~25MB total), removes partial downloads on failure
|
|
# @edgecases Handles network failures, validates file integrity (non-zero size), skips if already downloaded
|
|
# @calledby piper-download-voices.sh, manual voice download commands
|
|
# @calls parse_voice_components, verify_voice, get_voice_storage_dir, curl, rm
|
|
download_voice() {
|
|
local voice_name="$1"
|
|
local lang_code="${2:-}"
|
|
|
|
local voice_dir
|
|
voice_dir=$(get_voice_storage_dir)
|
|
|
|
# Check if already downloaded
|
|
if verify_voice "$voice_name"; then
|
|
echo "✅ Voice already downloaded: $voice_name"
|
|
return 0
|
|
fi
|
|
|
|
# Parse voice components
|
|
parse_voice_components "$voice_name"
|
|
|
|
# Construct download URLs
|
|
# Path format: {language}/{language}_{locale}/{speaker}/{quality}/{speaker}-{quality}.onnx
|
|
local model_path="${LANG}/${LANG}_${LOCALE}/${SPEAKER}/${QUALITY}/${voice_name}"
|
|
local onnx_url="${PIPER_VOICES_BASE_URL}/${model_path}.onnx"
|
|
local json_url="${PIPER_VOICES_BASE_URL}/${model_path}.onnx.json"
|
|
|
|
echo "📥 Downloading Piper voice: $voice_name"
|
|
echo " Source: HuggingFace (rhasspy/piper-voices)"
|
|
echo " Size: ~25MB"
|
|
echo ""
|
|
|
|
# Download ONNX model
|
|
echo " Downloading model file..."
|
|
if ! curl -L --progress-bar -o "$voice_dir/${voice_name}.onnx" "$onnx_url"; then
|
|
echo "❌ Failed to download voice model"
|
|
rm -f "$voice_dir/${voice_name}.onnx"
|
|
return 1
|
|
fi
|
|
|
|
# Download JSON config
|
|
echo " Downloading config file..."
|
|
if ! curl -L -s -o "$voice_dir/${voice_name}.onnx.json" "$json_url"; then
|
|
echo "❌ Failed to download voice config"
|
|
rm -f "$voice_dir/${voice_name}.onnx" "$voice_dir/${voice_name}.onnx.json"
|
|
return 1
|
|
fi
|
|
|
|
# Verify file integrity (basic check - file size > 0)
|
|
if [[ ! -s "$voice_dir/${voice_name}.onnx" ]]; then
|
|
echo "❌ Downloaded file is empty or corrupt"
|
|
rm -f "$voice_dir/${voice_name}.onnx" "$voice_dir/${voice_name}.onnx.json"
|
|
return 1
|
|
fi
|
|
|
|
echo "✅ Voice downloaded successfully: $voice_name"
|
|
echo " Location: $voice_dir/${voice_name}.onnx"
|
|
}
|
|
|
|
# @function list_downloaded_voices
|
|
# @intent Display all locally cached voice models with file sizes
|
|
# @why Help users see what voices they have available and storage usage
|
|
# @param None
|
|
# @returns None
|
|
# @exitcode Always 0
|
|
# @sideeffects Writes formatted list to stdout
|
|
# @edgecases Handles empty voice directory gracefully, uses nullglob to avoid literal *.onnx
|
|
# @calledby Voice management commands, /agent-vibes:list
|
|
# @calls get_voice_storage_dir, basename, du
|
|
list_downloaded_voices() {
|
|
local voice_dir
|
|
voice_dir=$(get_voice_storage_dir)
|
|
|
|
echo "📦 Downloaded Piper Voices:"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
|
|
local count=0
|
|
shopt -s nullglob
|
|
for onnx_file in "$voice_dir"/*.onnx; do
|
|
if [[ -f "$onnx_file" ]]; then
|
|
local voice_name
|
|
voice_name=$(basename "$onnx_file" .onnx)
|
|
local file_size
|
|
file_size=$(du -h "$onnx_file" | cut -f1)
|
|
echo " • $voice_name ($file_size)"
|
|
((count++))
|
|
fi
|
|
done
|
|
shopt -u nullglob
|
|
|
|
if [[ $count -eq 0 ]]; then
|
|
echo " (No voices downloaded yet)"
|
|
fi
|
|
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo "Total: $count voices"
|
|
}
|
|
|
|
# AI NOTE: This file manages the lifecycle of Piper voice models
|
|
# Voice models are ONNX files (~20-30MB each) downloaded from HuggingFace
|
|
# Files are cached locally to avoid repeated downloads
|
|
# Project-local storage preferred over global for isolation
|