|
#!/bin/bash |
|
|
|
# VOICEVOX say コマンド |
|
# 使用例: say "こんにちは" [スピーカー名] [スタイル名] [オプション] |
|
|
|
# デバッグモードでない場合のみset -euo pipefailを有効化 |
|
if [[ "${DEBUG_MODE:-false}" != true ]]; then |
|
set -euo pipefail |
|
fi |
|
|
|
# デフォルト設定 |
|
readonly DEFAULT_PORT=50021 |
|
readonly DEFAULT_SPEAKER="ずんだもん" |
|
readonly DEFAULT_STYLE="ノーマル" |
|
|
|
# キャッシュ設定 |
|
readonly CACHE_DIR="$HOME/.cache/voicevox_say" |
|
readonly SPEAKERS_CACHE="$CACHE_DIR/speakers.json" |
|
readonly SPEAKERS_MAP="$CACHE_DIR/speakers_map.sh" |
|
|
|
# 一時ファイル |
|
readonly TEMP_DIR="/tmp/voicevox_say_$$" |
|
readonly TEXT_FILE="$TEMP_DIR/text.txt" |
|
readonly QUERY_FILE="$TEMP_DIR/query.json" |
|
readonly AUDIO_FILE="$TEMP_DIR/audio.wav" |
|
|
|
# グローバル変数 |
|
declare -gA SPEAKER_STYLES |
|
|
|
# エラーハンドリング |
|
cleanup() { |
|
rm -rf "$TEMP_DIR" 2>/dev/null || true |
|
} |
|
trap cleanup EXIT |
|
|
|
error_exit() { |
|
echo "エラー: $1" >&2 |
|
if [[ "${DEBUG:-false}" == true ]]; then |
|
echo "デバッグ情報:" >&2 |
|
echo " 現在のディレクトリ: $(pwd)" >&2 |
|
echo " 一時ディレクトリ: $TEMP_DIR" >&2 |
|
echo " 一時ファイル存在確認:" >&2 |
|
echo " TEXT_FILE: $(ls -la "$TEXT_FILE" 2>/dev/null || echo '存在しない')" >&2 |
|
echo " QUERY_FILE: $(ls -la "$QUERY_FILE" 2>/dev/null || echo '存在しない')" >&2 |
|
echo " AUDIO_FILE: $(ls -la "$AUDIO_FILE" 2>/dev/null || echo '存在しない')" >&2 |
|
fi |
|
exit 1 |
|
} |
|
|
|
# ログ出力(クワイエットモード対応) |
|
log() { |
|
if [[ "${QUIET:-false}" == "false" ]]; then |
|
echo "$1" |
|
fi |
|
} |
|
|
|
# 必要なコマンドのチェック |
|
check_dependencies() { |
|
local -a required_commands=(jq curl aplay bc) |
|
for cmd in "${required_commands[@]}"; do |
|
command -v "$cmd" > /dev/null || error_exit "$cmd コマンドが見つかりません" |
|
done |
|
} |
|
|
|
# キャッシュディレクトリ作成 |
|
init_cache_dir() { |
|
mkdir -p "$CACHE_DIR" || error_exit "キャッシュディレクトリの作成に失敗しました: $CACHE_DIR" |
|
} |
|
|
|
# VOICEVOXサーバー接続確認 |
|
check_voicevox_server() { |
|
local port=${1:-$DEFAULT_PORT} |
|
if curl -s --connect-timeout 3 "127.0.0.1:$port/version" > /dev/null 2>&1; then |
|
return 0 |
|
else |
|
return 1 |
|
fi |
|
} |
|
|
|
# スピーカー情報をキャッシュから読み込み |
|
load_speakers_cache() { |
|
if [[ ! -f "$SPEAKERS_MAP" || ! -s "$SPEAKERS_MAP" ]]; then |
|
return 1 |
|
fi |
|
|
|
# グローバル変数として宣言 |
|
declare -gA SPEAKER_STYLES |
|
|
|
# sourceコマンドの結果を確実にキャプチャ |
|
if source "$SPEAKERS_MAP" 2>/dev/null; then |
|
if [[ ${#SPEAKER_STYLES[@]} -gt 0 ]]; then |
|
return 0 |
|
else |
|
return 1 |
|
fi |
|
else |
|
return 1 |
|
fi |
|
} |
|
|
|
# スピーカー情報を取得してキャッシュ更新 |
|
update_speakers_cache() { |
|
local port=${1:-$DEFAULT_PORT} |
|
|
|
# 古いファイルを削除 |
|
rm -f "$SPEAKERS_CACHE" "$SPEAKERS_MAP" 2>/dev/null || true |
|
|
|
# VOICEVOXサーバーの接続確認 |
|
if ! check_voicevox_server "$port"; then |
|
return 1 |
|
fi |
|
|
|
# スピーカー情報取得 |
|
if ! curl -s --connect-timeout 10 "127.0.0.1:$port/speakers" > "$SPEAKERS_CACHE" 2>/dev/null; then |
|
return 1 |
|
fi |
|
|
|
# ファイルサイズチェック |
|
if [[ ! -s "$SPEAKERS_CACHE" ]]; then |
|
return 1 |
|
fi |
|
|
|
# JSON妥当性チェック |
|
if ! jq empty "$SPEAKERS_CACHE" 2>/dev/null; then |
|
return 1 |
|
fi |
|
|
|
# speakers_map生成 |
|
echo "# Generated speaker map - $(date)" > "$SPEAKERS_MAP" |
|
echo "# SPEAKER_STYLES配列はグローバルで宣言済み" >> "$SPEAKERS_MAP" |
|
|
|
# jqコマンドでデータを抽出し、Bashでフォーマット |
|
if jq -r '.[] | .name as $speaker | .styles[] | select(.type == "talk") | "\($speaker),\(.name) \(.id)"' "$SPEAKERS_CACHE" 2>/dev/null | \ |
|
while IFS=' ' read -r speaker_style_name speaker_id; do |
|
echo "SPEAKER_STYLES[\"$speaker_style_name\"]=$speaker_id" >> "$SPEAKERS_MAP" |
|
done; then |
|
if [[ -s "$SPEAKERS_MAP" ]]; then |
|
return 0 |
|
else |
|
return 1 |
|
fi |
|
else |
|
return 1 |
|
fi |
|
} |
|
|
|
# スピーカー情報の初期化 |
|
init_speakers() { |
|
local port=${1:-$DEFAULT_PORT} |
|
|
|
# キャッシュから読み込み試行 |
|
if load_speakers_cache; then |
|
return 0 |
|
fi |
|
|
|
# キャッシュ更新 |
|
log "スピーカー情報を更新しています..." |
|
if update_speakers_cache "$port" && load_speakers_cache; then |
|
log "スピーカー情報を更新しました" |
|
return 0 |
|
fi |
|
|
|
error_exit "スピーカー情報の取得に失敗しました。VOICEVOXサーバー(ポート$port)が起動しているか確認してください" |
|
} |
|
|
|
# 利用可能なスピーカーを表示 |
|
list_speakers() { |
|
echo "利用可能なスピーカーとスタイル:" |
|
|
|
if [[ ${#SPEAKER_STYLES[@]} -eq 0 ]]; then |
|
echo " スピーカー情報が読み込まれていません" |
|
return 1 |
|
fi |
|
|
|
# 連想配列のキーを配列に変換してソート |
|
local keys=() |
|
for key in "${!SPEAKER_STYLES[@]}"; do |
|
keys+=("$key") |
|
done |
|
|
|
# ソート |
|
IFS=$'\n' |
|
local sorted_keys=($(sort <<<"${keys[*]}")) |
|
unset IFS |
|
|
|
local current_speaker="" |
|
for key in "${sorted_keys[@]}"; do |
|
local speaker="${key%,*}" |
|
local style="${key#*,}" |
|
|
|
if [[ "$speaker" != "$current_speaker" ]]; then |
|
echo " $speaker:" |
|
current_speaker="$speaker" |
|
fi |
|
echo " $style (ID: ${SPEAKER_STYLES[$key]})" |
|
done |
|
} |
|
|
|
# スピーカー名が有効かチェック |
|
is_valid_speaker() { |
|
local speaker="$1" |
|
local found=false |
|
for key in "${!SPEAKER_STYLES[@]}"; do |
|
if [[ "$key" =~ ^"$speaker", ]]; then |
|
found=true |
|
break |
|
fi |
|
done |
|
if [[ "$found" == true ]]; then |
|
return 0 |
|
else |
|
return 1 |
|
fi |
|
} |
|
|
|
# スタイル名が有効かチェック |
|
is_valid_style() { |
|
local speaker="$1" |
|
local style="$2" |
|
if [[ -n "${SPEAKER_STYLES["$speaker,$style"]:-}" ]]; then |
|
return 0 |
|
else |
|
return 1 |
|
fi |
|
} |
|
|
|
# スピーカーの最初のスタイルを取得(ノーマル優先) |
|
get_first_style() { |
|
local speaker="$1" |
|
|
|
# まず「ノーマル」スタイルがあるかチェック |
|
if [[ -n "${SPEAKER_STYLES["$speaker,ノーマル"]:-}" ]]; then |
|
echo "ノーマル" |
|
return 0 |
|
fi |
|
|
|
# ノーマルがない場合は最初に見つかったスタイルを返す |
|
for key in "${!SPEAKER_STYLES[@]}"; do |
|
if [[ "$key" =~ ^"$speaker",(.+)$ ]]; then |
|
echo "${BASH_REMATCH[1]}" |
|
return 0 |
|
fi |
|
done |
|
return 1 |
|
} |
|
|
|
# 数値パラメータの範囲チェック |
|
validate_range() { |
|
local value="$1" |
|
local min="$2" |
|
local max="$3" |
|
local param_name="$4" |
|
|
|
# 数値形式チェック |
|
local pattern="^-?[0-9]*\.?[0-9]+$" |
|
[[ "$value" =~ $pattern ]] || error_exit "${param_name}には数値を指定してください: $value" |
|
|
|
# 範囲チェック(bcを使用) |
|
if [[ $(echo "$value < $min" | bc -l) -eq 1 ]] || [[ $(echo "$value > $max" | bc -l) -eq 1 ]]; then |
|
error_exit "${param_name}は${min}から${max}の範囲で指定してください: $value" |
|
fi |
|
} |
|
|
|
# 音声パラメータの調整 |
|
adjust_audio_parameter() { |
|
local param_name="$1" |
|
local param_value="$2" |
|
local default_value="$3" |
|
local speaker_id="$4" |
|
local port="$5" |
|
|
|
if [[ "$param_value" != "$default_value" ]]; then |
|
# パラメータ値を更新 |
|
local pattern |
|
case "$param_name" in |
|
speedScale) pattern="\"speedScale\":[0-9.]+" ;; |
|
pitchScale) pattern="\"pitchScale\":-?[0-9.]+" ;; |
|
volumeScale) pattern="\"volumeScale\":[0-9.]+" ;; |
|
esac |
|
|
|
sed -i -r "s/$pattern/\"$param_name\":$param_value/" "$QUERY_FILE" || error_exit "${param_name}の変更に失敗しました" |
|
|
|
# 再合成 |
|
curl -s \ |
|
-H "Content-Type: application/json" \ |
|
-X POST \ |
|
-d @"$QUERY_FILE" \ |
|
"127.0.0.1:$port/synthesis?speaker=$speaker_id" \ |
|
> "$AUDIO_FILE" || error_exit "音声合成に失敗しました" |
|
fi |
|
} |
|
|
|
# ヘルプ表示 |
|
show_help() { |
|
cat << 'EOF' |
|
使用法: say <テキスト> [スピーカー名] [スタイル名] [オプション] |
|
|
|
引数: |
|
<テキスト> 読み上げるテキスト |
|
[スピーカー名] 話者名(デフォルト: ずんだもん) |
|
[スタイル名] スタイル名(デフォルト: ノーマル) |
|
|
|
オプション: |
|
-p, --port PORT ポート番号を指定(デフォルト: 50021) |
|
-o, --output [FILE] 音声ファイルを保存(デフォルト: output.wav) |
|
-q, --quiet ログメッセージを表示しない |
|
--speedScale SCALE 話す速度を指定(デフォルト: 1.0、範囲: 0.1-3.0) |
|
--pitchScale SCALE 声の高さを指定(デフォルト: 0.0、範囲: -0.15-0.15) |
|
--volumeScale SCALE 音量を指定(デフォルト: 1.0、範囲: 0.1-3.0) |
|
--list-speakers 利用可能なスピーカーとスタイルを表示 |
|
--update-cache スピーカー情報のキャッシュを強制更新 |
|
-h, --help このヘルプを表示 |
|
|
|
使用例: |
|
say "こんにちは" |
|
say "こんにちは" ずんだもん あまあま |
|
say "こんにちは" --speedScale 1.5 --pitchScale 0.1 |
|
say "こんにちは" --output greeting.wav --quiet |
|
say --list-speakers |
|
EOF |
|
} |
|
|
|
# メイン処理 |
|
main() { |
|
# 引数解析 |
|
local port=$DEFAULT_PORT |
|
local text="" |
|
local speaker_arg="" |
|
local style_arg="" |
|
local output_file="" |
|
local save_to_file=false |
|
local quiet=false |
|
local speed_scale="1.0" |
|
local pitch_scale="0.0" |
|
local volume_scale="1.0" |
|
local list_speakers_flag=false |
|
local update_cache_flag=false |
|
|
|
while [[ $# -gt 0 ]]; do |
|
case $1 in |
|
-p|--port) |
|
[[ -z "${2:-}" ]] && error_exit "ポート番号を指定してください" |
|
port="$2" |
|
shift 2 |
|
;; |
|
-o|--output) |
|
save_to_file=true |
|
if [[ $# -gt 1 && ! "$2" =~ ^- ]]; then |
|
output_file="$2" |
|
[[ "$output_file" == *.wav ]] || error_exit "出力ファイル名には .wav 拡張子が必要です: $output_file" |
|
shift 2 |
|
else |
|
output_file="output.wav" |
|
shift |
|
fi |
|
;; |
|
--speedScale) |
|
[[ -z "${2:-}" ]] && error_exit "speedScaleの値を指定してください" |
|
validate_range "$2" 0.1 3.0 "speedScale" |
|
speed_scale="$2" |
|
shift 2 |
|
;; |
|
--pitchScale) |
|
[[ -z "${2:-}" ]] && error_exit "pitchScaleの値を指定してください" |
|
validate_range "$2" -0.15 0.15 "pitchScale" |
|
pitch_scale="$2" |
|
shift 2 |
|
;; |
|
--volumeScale) |
|
[[ -z "${2:-}" ]] && error_exit "volumeScaleの値を指定してください" |
|
validate_range "$2" 0.1 3.0 "volumeScale" |
|
volume_scale="$2" |
|
shift 2 |
|
;; |
|
--list-speakers) |
|
list_speakers_flag=true |
|
shift |
|
;; |
|
--update-cache) |
|
update_cache_flag=true |
|
shift |
|
;; |
|
-q|--quiet) |
|
quiet=true |
|
shift |
|
;; |
|
-h|--help) |
|
show_help |
|
exit 0 |
|
;; |
|
-*) |
|
error_exit "不明なオプション: $1" |
|
;; |
|
*) |
|
if [[ -z "$text" ]]; then |
|
text="$1" |
|
elif [[ -z "$speaker_arg" ]]; then |
|
speaker_arg="$1" |
|
else |
|
style_arg="$1" |
|
fi |
|
shift |
|
;; |
|
esac |
|
done |
|
|
|
# グローバル変数設定 |
|
readonly QUIET=$quiet |
|
|
|
# 依存関係チェック |
|
check_dependencies |
|
init_cache_dir |
|
|
|
# 特別なオプションの処理 |
|
if [[ "$update_cache_flag" == true ]]; then |
|
echo "スピーカー情報のキャッシュを更新中..." |
|
if update_speakers_cache "$port"; then |
|
echo "キャッシュを更新しました" |
|
else |
|
error_exit "キャッシュの更新に失敗しました。VOICEVOXサーバー(ポート$port)が起動しているか確認してください" |
|
fi |
|
exit 0 |
|
fi |
|
|
|
if [[ "$list_speakers_flag" == true ]]; then |
|
init_speakers "$port" |
|
list_speakers |
|
exit 0 |
|
fi |
|
|
|
# 通常の音声合成処理 |
|
[[ -z "$text" ]] && error_exit "読み上げるテキストを指定してください" |
|
|
|
# スピーカー情報を初期化 |
|
init_speakers "$port" |
|
|
|
# スピーカーとスタイルの決定 |
|
local final_speaker=$DEFAULT_SPEAKER |
|
local final_style=$DEFAULT_STYLE |
|
|
|
if [[ -n "$speaker_arg" ]]; then |
|
if is_valid_speaker "$speaker_arg"; then |
|
final_speaker="$speaker_arg" |
|
if [[ -z "$style_arg" ]]; then |
|
final_style=$(get_first_style "$final_speaker") |
|
else |
|
final_style="$style_arg" |
|
fi |
|
else |
|
error_exit "サポートされていないスピーカーです: $speaker_arg (利用可能なスピーカーは --list-speakers で確認してください)" |
|
fi |
|
fi |
|
|
|
[[ -n "$style_arg" ]] && final_style="$style_arg" |
|
|
|
# スピーカーとスタイルの組み合わせチェック |
|
if ! is_valid_style "$final_speaker" "$final_style"; then |
|
error_exit "サポートされていないスピーカー/スタイルの組み合わせです: $final_speaker / $final_style (利用可能な組み合わせは --list-speakers で確認してください)" |
|
fi |
|
|
|
# スピーカーID取得 |
|
local speaker_id=${SPEAKER_STYLES["$final_speaker,$final_style"]} |
|
|
|
# 一時ディレクトリ作成 |
|
mkdir -p "$TEMP_DIR" || error_exit "一時ディレクトリの作成に失敗しました" |
|
|
|
# VOICEVOXサーバーの接続確認 |
|
check_voicevox_server "$port" || error_exit "VOICEVOXサーバー(ポート$port)に接続できません" |
|
|
|
log "読み上げ中: \"$text\" ($final_speaker / $final_style)" |
|
|
|
# テキストファイル作成 |
|
echo -n "$text" > "$TEXT_FILE" || error_exit "テキストファイルの作成に失敗しました" |
|
|
|
# 音声クエリ作成 |
|
curl -s \ |
|
-X POST \ |
|
"127.0.0.1:$port/audio_query?speaker=$speaker_id" \ |
|
--get --data-urlencode text@"$TEXT_FILE" \ |
|
> "$QUERY_FILE" || error_exit "音声クエリの作成に失敗しました" |
|
|
|
# 基本音声合成 |
|
curl -s \ |
|
-H "Content-Type: application/json" \ |
|
-X POST \ |
|
-d @"$QUERY_FILE" \ |
|
"127.0.0.1:$port/synthesis?speaker=$speaker_id" \ |
|
> "$AUDIO_FILE" || error_exit "音声合成に失敗しました" |
|
|
|
# 音声パラメータ調整 |
|
adjust_audio_parameter "speedScale" "$speed_scale" "1.0" "$speaker_id" "$port" |
|
adjust_audio_parameter "pitchScale" "$pitch_scale" "0.0" "$speaker_id" "$port" |
|
adjust_audio_parameter "volumeScale" "$volume_scale" "1.0" "$speaker_id" "$port" |
|
|
|
# 音声ファイルのサイズチェック |
|
[[ -s "$AUDIO_FILE" ]] || error_exit "音声ファイルの生成に失敗しました" |
|
|
|
# ファイル保存または音声再生 |
|
if [[ "$save_to_file" == true ]]; then |
|
log "DEBUG: ファイル保存を開始: $output_file" |
|
log "DEBUG: 音声ファイルサイズ: $(wc -c < "$AUDIO_FILE" 2>/dev/null || echo 0) bytes" |
|
|
|
if cp "$AUDIO_FILE" "$output_file" 2>/dev/null; then |
|
log "音声ファイルを保存しました: $output_file" |
|
log "DEBUG: 保存後のファイルサイズ: $(wc -c < "$output_file" 2>/dev/null || echo 0) bytes" |
|
else |
|
error_exit "音声ファイルの保存に失敗しました: $output_file" |
|
fi |
|
else |
|
# 音声再生(set -e を一時的に無効化) |
|
set +e |
|
aplay_result=0 |
|
if aplay -q "$AUDIO_FILE" 2>/dev/null; then |
|
aplay_result=0 |
|
else |
|
aplay_result=1 |
|
fi |
|
set -e |
|
|
|
if [[ $aplay_result -eq 0 ]]; then |
|
log "音声を再生しました" |
|
else |
|
log "警告: 音声の再生に失敗しましたが、音声ファイルは正常に生成されました" |
|
fi |
|
fi |
|
} |
|
|
|
# スクリプトが直接実行された場合のみmain関数を呼び出し |
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then |
|
main "$@" |
|
fi |