# app.py (VOICEVOX版) import random import gradio as gr import requests import tempfile import json # ========================= # セリフ素材 # ========================= COUNT_VARIANTS = { 100: ["ひゃく、、", "ひゃーく、、、", "ひゃーーーく、、、、"], 99: ["きゅうじゅうきゅう、、", "きゅーじゅーきゅー、、、"], 98: ["きゅうじゅうはち、、", "きゅーじゅーはち、、、"], 97: ["きゅうじゅうなな、、", "きゅーじゅーなな、、、"], 96: ["きゅうじゅうろく、、", "きゅーじゅーろく、、、"], 95: ["きゅうじゅうご、、", "きゅーじゅーご、、、"], 94: ["きゅうじゅうよん、、", "きゅーじゅーよん、、、"], 93: ["きゅうじゅうさん、、", "きゅーじゅーさん、、、"], 92: ["きゅうじゅうに、、", "きゅーじゅーに、、、"], 91: ["きゅうじゅういち、、", "きゅーじゅーいち、、、"], 90: ["きゅうじゅう、、", "きゅーじゅー、、、", "きゅーーーじゅー、、、、"], # 80番台 89: ["はちじゅうきゅう、、", "はーちじゅーきゅー、、、"], 88: ["はちじゅうはち、、", "はーちじゅーはち、、、"], 87: ["はちじゅうなな、、", "はーちじゅーなな、、、"], 86: ["はちじゅうろく、、", "はーちじゅーろく、、、"], 85: ["はちじゅうご、、", "はーちじゅーご、、、"], 84: ["はちじゅうよん、、", "はーちじゅーよん、、、"], 83: ["はちじゅうさん、、", "はーちじゅーさん、、、"], 82: ["はちじゅうに、、", "はーちじゅーに、、、"], 81: ["はちじゅういち、、", "はーちじゅーいち、、、"], 80: ["はちじゅう、、", "はーちじゅー、、、", "はーーーちじゅー、、、、"], # 70番台 79: ["ななじゅうきゅう、、", "なーなじゅーきゅー、、、"], 78: ["ななじゅうはち、、", "なーなじゅーはち、、、"], 77: ["ななじゅうなな、、", "なーなじゅーなな、、、"], 76: ["ななじゅうろく、、", "なーなじゅーろく、、、"], 75: ["ななじゅうご、、", "なーなじゅーご、、、"], 74: ["ななじゅうよん、、", "なーなじゅーよん、、、"], 73: ["ななじゅうさん、、", "なーなじゅーさん、、、"], 72: ["ななじゅうに、、", "なーなじゅーに、、、"], 71: ["ななじゅういち、、", "なーなじゅーいち、、、"], 70: ["ななじゅう、、", "なーなじゅー、、、", "なーーーなじゅー、、、、"], # 60番台 69: ["ろくじゅうきゅう、、", "ろーくじゅーきゅー、、、"], 68: ["ろくじゅうはち、、", "ろーくじゅーはち、、、"], 67: ["ろくじゅうなな、、", "ろーくじゅーなな、、、"], 66: ["ろくじゅうろく、、", "ろーくじゅーろく、、、"], 65: ["ろくじゅうご、、", "ろーくじゅーご、、、"], 64: ["ろくじゅうよん、、", "ろーくじゅーよん、、、"], 63: ["ろくじゅうさん、、", "ろーくじゅーさん、、、"], 62: ["ろくじゅうに、、", "ろーくじゅーに、、、"], 61: ["ろくじゅういち、、", "ろーくじゅーいち、、、"], 60: ["ろくじゅう、、", "ろーくじゅー、、、", "ろーーーくじゅー、、、、"], # 50番台 59: ["ごじゅうきゅう、、", "ごーじゅーきゅー、、、"], 58: ["ごじゅうはち、、", "ごーじゅーはち、、、"], 57: ["ごじゅうなな、、", "ごーじゅーなな、、、"], 56: ["ごじゅうろく、、", "ごーじゅーろく、、、"], 55: ["ごじゅうご、、", "ごーじゅーご、、、"], 54: ["ごじゅうよん、、", "ごーじゅーよん、、、"], 53: ["ごじゅうさん、、", "ごーじゅーさん、、、"], 52: ["ごじゅうに、、", "ごーじゅーに、、、"], 51: ["ごじゅういち、、", "ごーじゅーいち、、、"], 50: ["ごじゅう、、", "ごーじゅー、、、", "ごーーーじゅー、、、、"], # 40番台 49: ["よんじゅうきゅう、、", "よーんじゅーきゅー、、、"], 48: ["よんじゅうはち、、", "よーんじゅーはち、、、"], 47: ["よんじゅうなな、、", "よーんじゅーなな、、、"], 46: ["よんじゅうろく、、", "よーんじゅーろく、、、"], 45: ["よんじゅうご、、", "よーんじゅーご、、、"], 44: ["よんじゅうよん、、", "よーんじゅーよん、、、"], 43: ["よんじゅうさん、、", "よーんじゅーさん、、、"], 42: ["よんじゅうに、、", "よーんじゅーに、、、"], 41: ["よんじゅういち、、", "よーんじゅーいち、、、"], 40: ["よんじゅう、、", "よーんじゅー、、、", "よーーーんじゅー、、、、"], # 30番台 39: ["さんじゅうきゅう、、", "さーんじゅーきゅー、、、"], 38: ["さんじゅうはち、、", "さーんじゅーはち、、、"], 37: ["さんじゅうなな、、", "さーんじゅーなな、、、"], 36: ["さんじゅうろく、、", "さーんじゅーろく、、、"], 35: ["さんじゅうご、、", "さーんじゅーご、、、"], 34: ["さんじゅうよん、、", "さーんじゅーよん、、、"], 33: ["さんじゅうさん、、", "さーんじゅーさん、、、"], 32: ["さんじゅうに、、", "さーんじゅーに、、、"], 31: ["さんじゅういち、、", "さーんじゅーいち、、、"], 30: ["さんじゅう、、", "さーんじゅー、、、", "さーーーんじゅー、、、、"], # 20番台 29: ["にじゅうきゅう、、", "にーじゅーきゅー、、、"], 28: ["にじゅうはち、、", "にーじゅーはち、、、"], 27: ["にじゅうなな、、", "にーじゅーなな、、、"], 26: ["にじゅうろく、、", "にーじゅーろく、、、"], 25: ["にじゅうご、、", "にーじゅーご、、、"], 24: ["にじゅうよん、、", "にーじゅーよん、、、"], 23: ["にじゅうさん、、", "にーじゅーさん、、、"], 22: ["にじゅうに、、", "にーじゅーに、、、"], 21: ["にじゅういち、、", "にーじゅーいち、、、"], 20: ["にじゅう、、", "にーじゅー、、、", "にーーーじゅー、、、、"], # 10番台 19: ["じゅうきゅう、、", "じゅーきゅー、、、"], 18: ["じゅうはち、、", "じゅーはち、、、"], 17: ["じゅうなな、、", "じゅーなな、、、"], 16: ["じゅうろく、、", "じゅーろく、、、"], 15: ["じゅうご、、", "じゅーご、、、"], 14: ["じゅうよん、、", "じゅーよん、、、"], 13: ["じゅうさん、、", "じゅーさん、、、"], 12: ["じゅうに、、", "じゅーに、、、"], 11: ["じゅういち、、", "じゅーいち、、、"], 10: ["じゅう、、", "じゅーう、、、", "じゅーーーう、、、、"], 9: ["きゅう、、", "きゅーう、、、", "きゅーーーう、、、、"], 8: ["はち、、", "はーち、、、", "はーーーち、、、、"], 7: ["なな、、", "なーな、、、", "なーーーな、、、、"], 6: ["ろく、、", "ろーく、、、", "ろーーーく、、、、"], 5: ["ご、、", "ごー、、、", "ごーーー、、、、"], 4: ["よん、、", "よーん、、、", "よーーーん、、、、"], 3: ["さん、、", "さーん、、、", "さーーーん、、、、"], 2: ["に、、", "にー、、、", "にーーー、、、、", "にい、、、"], 1: ["いち、、", "いーち、、、", "いーーーち、、、、"], 0: ["ぜろ", "ぜーろ", "ぜろっ", "ぜろぜろぜろ", "、、、、、ぜーーーーーーーろ、、"], } # 長めの読み方の重み付け(長い読み方ほど確率高く) def get_count_variant(num): variants = COUNT_VARIANTS.get(num, [str(num) + "、、"]) # 長めの読み方を多めに選ぶ(後ろの要素ほど確率高) # バリエーション数に応じて重みを生成 if len(variants) == 1: return variants[0] elif len(variants) == 2: weights = [1, 2] elif len(variants) == 3: weights = [1, 2, 3] else: # 4以上 weights = [1, 2, 3, 4][:len(variants)] return random.choices(variants, weights=weights)[0] INTERRUPTS = [ "はい、ストップ", "だめ、まだ", "おあずけ", "ふふ、止めちゃおうかな", "あーあ、残念", "今のなし", "最初から、ね", "、、お、あ、ず、け、、", ] REACTIONS = [ "ふふ", "ん", "あーあ", "焦ってる?", "そんな顔しないの", "かわいいね、", "がーまーん、", "ほーら、", ] # 新レイヤー①: 状態を匂わせる独り言 OBSERVATIONS = [ "呼吸、早くなってる", "……今、目そらしたでしょ", "そんなに待てない?", "手、動いてるよ", "あーあ、正直だね", "我慢できてる?", "顔、赤いよ", "震えてる", ] # 新レイヤー②: 支配/主導権を示す一言 CONTROL_LINES = [ "まだ、私のペース", "勝手に進まないの", "ちゃんと聞いて", "逃げ道、ないよ", "ほら、続けるよ", "私が決めるの", "焦らないで", ] # 後半専用セリフ(進行度70%以降) LATE_GAME_LINES = [ "ここまで来たのに", "もう、戻れないよ", "最後まで付き合って", "逃げると思った?", "もうすぐだよ", "頑張って", ] # 0直前専用(1〜3の時) ALMOST_ZERO = [ "……言わないよ?", "まだ、だめ", "期待してる顔", "ほら、止まった", "もう少しだけ", "焦らないで", ] # 優しいふり(ギャップ用) FAKE_KINDNESS = [ "大丈夫、ちゃんとするから", "安心して", "怖くないよ", "信じて", ] # ========================= # ユーティリティ # ========================= def interrupt_rate(max_count): if max_count == 100: return 0.25 if max_count == 30: return 0.18 return 0.12 # ========================= # 台本生成 # ========================= def generate_script(max_count: int) -> str: script = [] current = max_count interrupt_count = 0 MAX_INTERRUPTS = 10 # 最初の何カウント分は中断やリアクションを入れないか initial_countdown = random.randint(3, 5) counts_done = 0 # 最近使ったセリフを記憶(重複回避用) recent_interrupts = [] recent_reactions = [] recent_observations = [] recent_control = [] recent_late_game = [] recent_almost_zero = [] recent_fake = [] def get_unique_item(pool, recent_list, history_size=3): """最近使ったものを避けて選択""" available = [item for item in pool if item not in recent_list] if not available: available = pool selected = random.choice(available) recent_list.append(selected) if len(recent_list) > history_size: recent_list.pop(0) return selected while current >= 0: # 進行度を計算 progress = (max_count - current) / max_count if max_count > 0 else 1.0 is_late_game = progress > 0.7 is_almost_zero = current <= 3 and current > 0 # カウント(既に間を含む) variant = get_count_variant(current) script.append(variant) # 0の場合は複数回繰り返す if current == 0: repeat_count = random.randint(4, 10) for _ in range(repeat_count - 1): script.append("、、、") script.append(get_count_variant(0)) break counts_done += 1 # 最初の数カウントは他のセリフを挟まない if counts_done <= initial_countdown: current -= 1 continue # === 0直前専用の溜め地獄 === if is_almost_zero and random.random() < 0.5: almost = get_unique_item(ALMOST_ZERO, recent_almost_zero) script.append(almost) script.append("、、、、、") current -= 1 continue # 追加の間(長めに) if random.random() < 0.4: script.append("、、、") # === 優しいふり(ギャップ) === # 低確率で発動し、直後に中断の可能性 if random.random() < 0.15: fake = get_unique_item(FAKE_KINDNESS, recent_fake) script.append(fake) script.append("、、、、、") # ギャップ演出:50%で直後に中断 if progress >= 0.5 and interrupt_count < MAX_INTERRUPTS and random.random() < 0.5: interrupt = get_unique_item(INTERRUPTS, recent_interrupts) script.append(interrupt) script.append("。、、、、、") current = max_count interrupt_count += 1 counts_done = 0 continue # === リアクション(1〜3個連続) === if random.random() < 0.4: reaction_count = random.choices([1, 2, 3], weights=[5, 3, 1])[0] for i in range(reaction_count): reaction = get_unique_item(REACTIONS, recent_reactions, history_size=5) script.append(reaction) if i < reaction_count - 1: script.append("、、") else: script.append("、、、") # === リアクション直後に独り言 === if random.random() < 0.4: observation = get_unique_item(OBSERVATIONS, recent_observations) script.append(observation) script.append("、、、、") # === 後半専用セリフ === if is_late_game and random.random() < 0.25: late_line = get_unique_item(LATE_GAME_LINES, recent_late_game) script.append(late_line) script.append("、、、") # === 支配/主導権セリフ === if random.random() < 0.2: control = get_unique_item(CONTROL_LINES, recent_control) script.append(control) script.append("、、、") # === 中断判定(半分以降) === can_interrupt = progress >= 0.5 # 中断の直前に独り言を入れる(効果大) if can_interrupt and interrupt_count < MAX_INTERRUPTS and random.random() < interrupt_rate(max_count): # 50%で中断前に独り言 if random.random() < 0.5: observation = get_unique_item(OBSERVATIONS, recent_observations) script.append(observation) script.append("、、、") interrupt = get_unique_item(INTERRUPTS, recent_interrupts) script.append(interrupt) script.append("。、、、、、") current = max_count interrupt_count += 1 counts_done = 0 continue current -= 1 return "".join(script) # ========================= # VOICEVOX API (Web版) # ========================= def tts_voicevox(text: str, speaker_id: int = 3) -> str: """ VOICEVOX Web APIを使用して音声合成 speaker_id: 3 = ずんだもん(ノーマル) """ try: import urllib.parse import time # URLエンコード encoded_text = urllib.parse.quote(text) # GETリクエスト用URL url = f"https://api.tts.quest/v3/voicevox/synthesis?speaker={speaker_id}&text={encoded_text}" print(f"[TTS] リクエスト送信中... (テキスト長: {len(text)}文字)") # API呼び出し response = requests.get(url, timeout=60) if response.status_code == 200: result = response.json() print(f"[TTS] APIレスポンス: success={result.get('success')}") if result.get("success"): status_url = result.get("audioStatusUrl") wav_url = result.get("wavDownloadUrl") if not wav_url: print(f"[TTS] wavDownloadUrlが見つかりません") return None # 音声生成完了を待つ(台本の長さに応じて動的に設定) # 間やセリフが増えたので係数を大幅に上げる # 目安: 1文字あたり1秒 + 基本60秒の余裕 estimated_time = len(text) * 1.0 + 60 max_wait_time = max(90, int(estimated_time)) # 最低90秒 print(f"[TTS] 音声生成待機中... (推定待機時間: 最大{max_wait_time}秒)") for attempt in range(max_wait_time): # ステータス確認 if status_url: try: status_response = requests.get(status_url, timeout=10) if status_response.status_code == 200: status_data = status_response.json() if status_data.get("isAudioReady"): print(f"[TTS] 音声生成完了!(待機時間: {attempt}秒)") break except: pass # 直接WAVファイルを試す try: test_response = requests.head(wav_url, timeout=10) if test_response.status_code == 200: print(f"[TTS] 音声ファイル確認成功 ({attempt + 1}秒待機)") break except: pass if attempt % 10 == 0: # 10秒ごとにログ出力 print(f"[TTS] 待機中... ({attempt + 1}/{max_wait_time}秒)") time.sleep(1) # WAVファイルをダウンロード print(f"[TTS] 音声ダウンロード中... URL: {wav_url}") audio_response = requests.get(wav_url, timeout=60) if audio_response.status_code == 200: print(f"[TTS] ダウンロード成功 (サイズ: {len(audio_response.content)} bytes)") tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".wav") tmp.write(audio_response.content) tmp.close() print(f"[TTS] ファイル保存完了: {tmp.name}") return tmp.name else: print(f"[TTS] 音声ダウンロード失敗: {audio_response.status_code}") return None else: print(f"[TTS] API success=False") return None else: print(f"[TTS] APIエラー: {response.status_code}") print(f"レスポンス: {response.text}") return None except Exception as e: print(f"[TTS] 例外発生: {type(e).__name__}: {e}") import traceback traceback.print_exc() return None # ========================= # Gradio用関数 # ========================= def generate_and_speak(count_choice, speaker_choice): max_count = int(count_choice) script = generate_script(max_count) # 話者IDマップ(全キャラクター対応) speaker_map = { # 四国めたん "四国めたん(あまあま)": 0, "四国めたん(ノーマル)": 2, "四国めたん(セクシー)": 4, "四国めたん(ツンツン)": 6, "四国めたん(ささやき)": 36, "四国めたん(ひそひそ)": 37, # 春日部つむぎ "春日部つむぎ": 8, # 雨晴はう "雨晴はう": 10, # 冥鳴ひまり "冥鳴ひまり": 14, # 九州そら "九州そら(あまあま)": 15, "九州そら(ノーマル)": 16, "九州そら(セクシー)": 17, "九州そら(ツンツン)": 18, "九州そら(ささやき)": 19, # もち子さん "もち子さん(ノーマル)": 20, "もち子さん(セクシー/あん子)": 66, "もち子さん(泣き)": 67, "もち子さん(怒り)": 68, "もち子さん(喜び)": 69, "もち子さん(のんびり)": 70, # WhiteCUL "WhiteCUL(かなしい)": 25, # 後鬼 "後鬼(人間ver.)": 27, # No.7 "No.7(読み聞かせ)": 31, # 小夜/SAYO "小夜/SAYO": 46, # ナースロボ_タイプT "ナースロボ_タイプT(ノーマル)": 47, "ナースロボ_タイプT(楽々)": 48, "ナースロボ_タイプT(恐怖)": 49, "ナースロボ_タイプT(内緒話)": 50, # 猫使ビィ "猫使ビィ(おちつき)": 58, "猫使ビィ(人見知り)": 59, # 満別花丸 "満別花丸(ささやき)": 61, # 中部つるぎ "中部つるぎ(ノーマル)": 67, "中部つるぎ(怒り)": 68, "中部つるぎ(ひそひそ)": 69, # ユーレイちゃん "ユーレイちゃん(甘々)": 63, "ユーレイちゃん(哀しみ)": 64, "ユーレイちゃん(ささやき)": 65, # 東北ずん子 "東北ずん子": 71, # 東北きりたん "東北きりたん": 72, # あんこもん "あんこもん(ささやき)": 73, } speaker_id = speaker_map.get(speaker_choice, 2) # デフォルトは四国めたん(ノーマル) # 推定待機時間を計算(間とセリフ増加に対応) estimated_time = len(script) * 1.0 + 60 max_wait = max(90, int(estimated_time)) # 台本の長さに応じた情報 if len(script) > 200: info = f"⚠️ 台本が長いため音声生成に時間がかかります\n({len(script)}文字 / 最大{max_wait}秒待機)\n\n" else: info = f"📊 台本: {len(script)}文字 / 推定待機時間: 最大{max_wait}秒\n\n" try: audio_path = tts_voicevox(script, speaker_id) if audio_path: return audio_path, info + script else: return None, f"{info}⚠️ 音声生成に失敗しました(タイムアウトの可能性があります)\n\n台本:\n{script}" except Exception as e: return None, f"{info}エラー: {str(e)}\n\n台本:\n{script}" # ========================= # UI # ========================= with gr.Blocks() as demo: gr.Markdown("# 🎙️ ゆっくりカウントダウン (VOICEVOX版)") gr.Markdown("ゆっくりボイスで焦らされるカウントダウン") count_choice = gr.Dropdown( ["5", "10", "30", "100"], value="10", label="カウント数" ) speaker_choice = gr.Dropdown( [ "四国めたん(あまあま)", "四国めたん(ノーマル)", "四国めたん(セクシー)", "四国めたん(ツンツン)", "四国めたん(ささやき)", "四国めたん(ひそひそ)", "春日部つむぎ", "雨晴はう", "冥鳴ひまり", "九州そら(あまあま)", "九州そら(ノーマル)", "九州そら(セクシー)", "九州そら(ツンツン)", "九州そら(ささやき)", "もち子さん(ノーマル)", "もち子さん(セクシー/あん子)", "もち子さん(泣き)", "もち子さん(怒り)", "もち子さん(喜び)", "もち子さん(のんびり)", "WhiteCUL(かなしい)", "後鬼(人間ver.)", "No.7(読み聞かせ)", "小夜/SAYO", "ナースロボ_タイプT(ノーマル)", "ナースロボ_タイプT(楽々)", "ナースロボ_タイプT(恐怖)", "ナースロボ_タイプT(内緒話)", "猫使ビィ(おちつき)", "猫使ビィ(人見知り)", "満別花丸(ささやき)", "中部つるぎ(ノーマル)", "中部つるぎ(怒り)", "中部つるぎ(ひそひそ)", "ユーレイちゃん(甘々)", "ユーレイちゃん(哀しみ)", "ユーレイちゃん(ささやき)", "東北ずん子", "東北きりたん", "あんこもん(ささやき)", ], value="四国めたん(ノーマル)", label="声の種類" ) btn = gr.Button("▶️ スタート", variant="primary") audio_out = gr.Audio(label="🔊 音声再生") script_out = gr.Textbox( label="📝 生成された台本", lines=8 ) btn.click( fn=generate_and_speak, inputs=[count_choice, speaker_choice], outputs=[audio_out, script_out] ) gr.Markdown("---") gr.Markdown("💡 **仕様**") gr.Markdown("- カウントダウン中にランダムで中断されます(最大10回まで、進行度50%以降)") gr.Markdown("- 待機時間は台本の長さに応じて自動調整(1文字=1秒 + 余裕60秒、最低90秒)") gr.Markdown("- 例: 100文字の台本 → 最大160秒待機、200文字 → 最大260秒待機") gr.Markdown("🎤 使用: VOICEVOX Web API (tts.quest)") demo.launch(theme=gr.themes.Soft())