Spaces:
Sleeping
Sleeping
| # app.py (VOICEVOX版) | |
| import random | |
| import gradio as gr | |
| import requests | |
| import tempfile | |
| import json | |
| import numpy as np | |
| from scipy.io import wavfile | |
| # ========================= | |
| # セリフ素材 | |
| # ========================= | |
| 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, 5][: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 = [ | |
| "大丈夫。。ちゃんとするから", | |
| "安心して", | |
| "怖くないよ", | |
| "信じて", | |
| ] | |
| # ========================= | |
| # スタート演出 | |
| # ========================= | |
| START_LINES_NORMAL = [ | |
| "……じゃあ、いくよ", | |
| "準備、いい?", | |
| "ちゃんと聞いて", | |
| "逃げないでね", | |
| "ほら、足、開いて。", | |
| ] | |
| START_LINES_WHISPER = [ | |
| "……ふふ", | |
| "……静かだね", | |
| "……聞こえてる?", | |
| "……大丈夫", | |
| ] | |
| # ========================= | |
| # ユーティリティ | |
| # ========================= | |
| def interrupt_rate(max_count): | |
| if max_count == 100: | |
| return 0.25 | |
| if max_count == 30: | |
| return 0.18 | |
| return 0.12 | |
| # add by GPT | |
| # バイノーラル風にする | |
| def mono_to_pseudo_binaural( | |
| wav_path, | |
| side="random", # "left", "right", "center", "random" | |
| delay_ms=6, | |
| other_gain=0.4 | |
| ): | |
| sr, data = wavfile.read(wav_path) | |
| # モノラル前提(VOICEVOXは int16 モノラル) | |
| if data.ndim > 1: | |
| return wav_path | |
| mono = data.astype(np.float32) | |
| if side == "random": | |
| side = random.choice(["left", "right"]) | |
| if side == "center": | |
| stereo = np.stack([mono, mono], axis=1) | |
| else: | |
| delay_samples = int(sr * delay_ms / 1000) | |
| def delay(sig): | |
| if delay_samples <= 0: | |
| return sig | |
| return np.concatenate([ | |
| np.zeros(delay_samples), | |
| sig[:-delay_samples] | |
| ]) | |
| if side == "left": | |
| left = mono | |
| right = delay(mono) * other_gain | |
| else: | |
| right = mono | |
| left = delay(mono) * other_gain | |
| stereo = np.stack([left, right], axis=1) | |
| # クリップ防止 | |
| stereo = np.clip(stereo, -32768, 32767).astype(np.int16) | |
| out = tempfile.NamedTemporaryFile(delete=False, suffix=".wav") | |
| wavfile.write(out.name, sr, stereo) | |
| out.close() | |
| return out.name | |
| # ========================= | |
| # 台本生成 | |
| # ========================= | |
| def generate_script(max_count: int) -> str: | |
| script = [] | |
| # add by gpt:スタート演出 | |
| script.extend(get_start_sequence()) | |
| ### | |
| 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 | |
| # SSMLタグでラップ | |
| final_script = "" + "".join(script) + "" | |
| return final_script | |
| # added by gpt | |
| def get_start_sequence(): | |
| """ | |
| 開始前の無音+導入セリフを生成 | |
| 戻り値: list[str] | |
| """ | |
| seq = [] | |
| # 開始前無音(0.5〜2.5秒相当) | |
| silent_count = random.randint(3, 10) # 1個 ≒ 0.25秒想定 | |
| seq.append("、" * silent_count) | |
| r = random.random() | |
| # ③ セリフなし(即カウント) 約25% | |
| if r < 0.25: | |
| return seq | |
| # 囁き・独り言 約25% | |
| elif r < 0.5: | |
| seq.append(random.choice(START_LINES_WHISPER)) | |
| seq.append("、、、、") | |
| return seq | |
| # フェイント開始 約15% | |
| elif r < 0.65: | |
| seq.append(random.choice(START_LINES_NORMAL)) | |
| seq.append("、、、、") | |
| seq.append("……まだ") | |
| seq.append("、、、、") | |
| return seq | |
| # 通常導入 約35% | |
| else: | |
| seq.append(random.choice(START_LINES_NORMAL)) | |
| seq.append("、、、、") | |
| return seq | |
| # ========================= | |
| # VOICEVOX API (Web版) | |
| # ========================= | |
| def tts_voicevox(text: str, speaker_id: int = 3) -> str: | |
| """ | |
| VOICEVOX Web APIを使用して音声合成 | |
| """ | |
| 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, | |
| "もち子さん(泣き)": 77, | |
| "もち子さん(怒り)": 78, | |
| "もち子さん(喜び)": 79, | |
| "もち子さん(のんびり)": 80, | |
| # WhiteCUL | |
| "WhiteCUL(かなしい)": 25, | |
| # 後鬼 | |
| "後鬼(鬼ver.)": 88, | |
| # No.7 | |
| "No.7(読み聞かせ)": 31, | |
| # 小夜/SAYO | |
| "小夜/SAYO": 46, | |
| # ナースロボ_タイプT | |
| "ナースロボ_タイプT(ノーマル)": 47, | |
| "ナースロボ_タイプT(楽々)": 48, | |
| "ナースロボ_タイプT(恐怖)": 49, | |
| "ナースロボ_タイプT(内緒話)": 50, | |
| # 猫使ビィ | |
| "猫使ビィ(おちつき)": 59, | |
| "猫使ビィ(人見知り)": 60, | |
| # 満別花丸 | |
| "満別花丸(ささやき)": 71, | |
| # 中部つるぎ | |
| "中部つるぎ(ノーマル)": 94, | |
| "中部つるぎ(怒り)": 95, | |
| "中部つるぎ(ひそひそ)": 96, | |
| # ユーレイちゃん | |
| "ユーレイちゃん(甘々)": 103, | |
| "ユーレイちゃん(哀しみ)": 104, | |
| "ユーレイちゃん(ささやき)": 105, | |
| # 東北ずん子 | |
| "東北ずん子": 107, | |
| # 東北きりたん | |
| "東北きりたん": 108, | |
| # あんこもん | |
| "あんこもん(ささやき)": 117, | |
| } | |
| 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: | |
| # add by gpt | |
| audio_path = mono_to_pseudo_binaural( | |
| audio_path, | |
| side="random" # ← とりあえずランダム | |
| ) | |
| ## | |
| 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("- 句読点の数で間を制御(多いほど長い間)") | |
| gr.Markdown("🎤 使用: VOICEVOX Web API (tts.quest)") | |
| demo.launch(theme=gr.themes.Soft()) |