"""ハコブくんの声(ElevenLabs)で、台本を1文ずつ音声にする。エイイチの Mac で動かす(API キーを Claude に渡さないため)。 準備(1回だけ) 1. ElevenLabs の有料プランに入る(無料プランの音声は商用に使えない) 2. Voice Design で声を作って保存し、その声の Voice ID を控える(Voices の一覧で声を開くと出る) 3. ターミナルで API キーを入れる(キーは画面やファイルに残さない): export ELEVENLABS_API_KEY=(キー) 使い方(動画の作業フォルダで。例: 動画/AIエージェントの取り違え/) python3 ../_制作ベース/tts_eleven.py --voice VOICE_ID --name 候補A --lines 0-12 --lines 作る文の番号(0 から。「0-12」「5,8,9」のように書く。省略するとすべて) --model eleven_v4(既定。2026-10-12 に決定)/eleven_multilingual_v2/eleven_v3 --seed 同じ値なら、なるべく同じ読み方になる(既定 7) --try 台本ではなく、試しの文を作る(「|」で区切って何文でも。例: --try "エイイチは…。|栄一は…。") 出力は voice_eleven/{名前}/試し_01.mp3 … と、どの文かの一覧 試し.txt 出力: voice_eleven/{名前}/{番号}.mp3 と、作った設定の記録 voice_eleven/{名前}/設定.json このあと Claude が tts_import.py で 48kHz の wav にして、口の動きと字幕に使う。 使う文字数(クレジット)は、文の字数の合計。1本約4,100字。作り直した分も数える """ import argparse, json, os, sys, time, urllib.request, urllib.error sys.path.insert(0, '.') from narration import SEGS try: from narration import parse except ImportError: def parse(s): return s[0], "base", s[1], (s[2] if len(s) > 2 else 0.35) try: from narration import ELEVEN_READ # ElevenLabs が読み間違えた語だけ、ここで読みを直す(字幕は変えない) except ImportError: ELEVEN_READ = [] ap = argparse.ArgumentParser() ap.add_argument('--voice', required=True) ap.add_argument('--name', required=True) ap.add_argument('--lines', default='') ap.add_argument('--model', default='eleven_v4') # 2026-10-12 エイイチが v4 に決めた(冒頭14文で v2 と聞き比べ) ap.add_argument('--seed', type=int, default=7) ap.add_argument('--stability', type=float, default=0.55) ap.add_argument('--similarity', type=float, default=0.75) ap.add_argument('--try', dest='trial', default='') args = ap.parse_args() key = os.environ.get('ELEVENLABS_API_KEY') if not key: sys.exit('ELEVENLABS_API_KEY が入っていません。ターミナルで export ELEVENLABS_API_KEY=(キー) を実行してから動かしてください') def nums(spec, n): if not spec: return list(range(n)) out = [] for part in spec.split(','): if '-' in part: a, b = part.split('-'); out += list(range(int(a), int(b) + 1)) else: out.append(int(part)) return [i for i in out if 0 <= i < n] def speak_text(t): t = t.replace('|', '').replace('「', '').replace('」', '') for a, b in ELEVEN_READ: t = t.replace(a, b) return t texts = [parse(s)[2] for s in SEGS] outdir = os.path.join('voice_eleven', args.name) if args.trial: # 試しの文だけを作る(台本の読みの直し ELEVEN_READ は使わない。書いたとおりに読ませて聞き比べる) os.makedirs(outdir, exist_ok=True) lines = [t for t in args.trial.split('|') if t] with open(os.path.join(outdir, '試し.txt'), 'w', encoding='utf-8') as f: for k, t in enumerate(lines, 1): f.write(f'試し_{k:02d}: {t}\n') for k, t in enumerate(lines, 1): body = {'text': t, 'model_id': args.model, 'seed': args.seed, 'voice_settings': {'stability': args.stability, 'similarity_boost': args.similarity}} req = urllib.request.Request( f'https://api.elevenlabs.io/v1/text-to-speech/{args.voice}?output_format=mp3_44100_128', data=json.dumps(body).encode(), headers={'xi-api-key': key, 'Content-Type': 'application/json'}) try: with urllib.request.urlopen(req, timeout=120) as r: open(os.path.join(outdir, f'試し_{k:02d}.mp3'), 'wb').write(r.read()) except urllib.error.HTTPError as e: sys.exit(f'試し_{k:02d} で止まりました: {e.code} {e.read().decode(errors="ignore")[:300]}') print(f'試し_{k:02d} {t}') print(f'できあがり: {outdir} 使った文字数 約{sum(len(t) for t in lines)}字') sys.exit(0) os.makedirs(outdir, exist_ok=True) json.dump(dict(vars(args), time=time.strftime('%Y-%m-%d %H:%M')), open(os.path.join(outdir, '設定.json'), 'w'), ensure_ascii=False, indent=1) used = 0 for i in nums(args.lines, len(SEGS)): if not texts[i]: continue prev = next((speak_text(texts[j]) for j in range(i - 1, -1, -1) if texts[j]), None) nxt = next((speak_text(texts[j]) for j in range(i + 1, len(texts)) if texts[j]), None) body = {'text': speak_text(texts[i]), 'model_id': args.model, 'seed': args.seed, 'voice_settings': {'stability': args.stability, 'similarity_boost': args.similarity}} if args.model.startswith('eleven_multilingual'): # 前後の文を渡すと、文のつながりの抑揚が自然になる(対応を確かめたモデルだけ) body['previous_text'] = prev body['next_text'] = nxt req = urllib.request.Request( f'https://api.elevenlabs.io/v1/text-to-speech/{args.voice}?output_format=mp3_44100_128', data=json.dumps(body).encode(), headers={'xi-api-key': key, 'Content-Type': 'application/json'}) try: with urllib.request.urlopen(req, timeout=120) as r: open(os.path.join(outdir, f'{i:03d}.mp3'), 'wb').write(r.read()) except urllib.error.HTTPError as e: sys.exit(f'{i:03d} で止まりました: {e.code} {e.read().decode(errors="ignore")[:300]}') except urllib.error.URLError as e: sys.exit(f'{i:03d} で止まりました(ElevenLabs につながらない): {e.reason}') used += len(body['text']) print(f'{i:03d} {body["text"][:30]}') print(f'できあがり: {outdir} 使った文字数 約{used}字')