File size: 5,204 Bytes
4616098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
"""Generate ElevenLabs premium voiceover WITH word-level timestamps for the
DocDoe ad reel, so on-screen captions land exactly with the spoken word.

Uses POST /v1/text-to-speech/{voice}/with-timestamps → returns audio + char
alignment. We request PCM 24k, wrap to WAV, and fold char timings into word
timings. Writes public/ad-vo/<id>.wav + manifest.json (with `words`).

  EL_KEY=sk_... backend/.venv/Scripts/python.exe backend/scripts/generate_ad_vo.py
"""
from __future__ import annotations

import base64
import json
import os
import sys
import time
import wave
from pathlib import Path

import requests

ROOT = Path(__file__).resolve().parents[2]
OUT_DIR = ROOT / "public" / "ad-vo"
OUT_DIR.mkdir(parents=True, exist_ok=True)

VOICE_ID = "nPczCjzI2devNBz1zQrb"  # Brian — deep, resonant, crystal-clear enunciation
MODEL = "eleven_multilingual_v2"   # reliable char timestamps
SAMPLE_RATE = 24000
OUTPUT_FORMAT = "pcm_24000"
# higher stability = cleaner, steadier delivery + crisp consonants
VOICE_SETTINGS = {"stability": 0.6, "similarity_boost": 0.9, "style": 0.1, "use_speaker_boost": True}

# "DocDoe" -> "Dock Doe" so TTS says it correctly ("dock-doh").
# Captions re-merge "Dock"+"Doe" -> "DocDoe" and "A"+"I" -> "AI" for display.
SEGMENTS: list[dict[str, str]] = [
    {"id": "hook", "text": "Studying all night, but still scoring low?"},
    {"id": "problem", "text": "Your textbook is huge. The exam tests only what matters."},
    {"id": "intro", "text": "Meet Dock Doe. Your A.I. exam coach."},
    {"id": "upload", "text": "Upload a PDF. Ask any question. Get an exam ready answer."},
    {"id": "features", "text": "Notes, quizzes, flashcards, and mark wise answers, in seconds."},
    {"id": "predict", "text": "Dock Doe predicts the important questions, and writes mark wise answers."},
    {"id": "video", "text": "Need a teacher? Dock Doe builds a full lesson, just for you."},
    {"id": "proof", "text": "Free to start. Built for every board and subject."},
    {"id": "cta", "text": "Start free today, at Dock Doe dot in."},
]


def synth(text: str, key: str) -> dict:
    url = f"https://api.elevenlabs.io/v1/text-to-speech/{VOICE_ID}/with-timestamps"
    params = {"output_format": OUTPUT_FORMAT}
    body = {"text": text, "model_id": MODEL, "voice_settings": VOICE_SETTINGS}
    headers = {"xi-api-key": key, "Content-Type": "application/json"}
    last = None
    for attempt in range(5):
        try:
            resp = requests.post(url, params=params, json=body, headers=headers, timeout=120)
            if resp.status_code != 200:
                raise SystemExit(f"ElevenLabs {resp.status_code}: {resp.text[:300]}")
            return resp.json()
        except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc:
            last = exc
            time.sleep(2 * (attempt + 1))
            print(f"   retry {attempt + 1}/5", flush=True)
    raise SystemExit(f"ElevenLabs unreachable: {last}")


def write_wav(pcm: bytes, path: Path) -> float:
    with wave.open(str(path), "wb") as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(SAMPLE_RATE)
        w.writeframes(pcm)
    with wave.open(str(path), "rb") as r:
        return round(r.getnframes() / float(r.getframerate()), 3)


def chars_to_words(text: str, chars: list[str], starts: list[float], ends: list[float]) -> list[dict]:
    """Fold per-character alignment into per-word [text,start,end]."""
    words: list[dict] = []
    cur = ""
    cur_start = None
    cur_end = 0.0
    for ch, st, en in zip(chars, starts, ends):
        if ch.strip() == "":
            if cur:
                words.append({"w": cur, "start": round(cur_start or 0, 3), "end": round(cur_end, 3)})
                cur, cur_start = "", None
            continue
        if cur_start is None:
            cur_start = st
        cur += ch
        cur_end = en
    if cur:
        words.append({"w": cur, "start": round(cur_start or 0, 3), "end": round(cur_end, 3)})
    return words


def main() -> int:
    key = os.environ.get("EL_KEY", "").strip()
    if not key:
        raise SystemExit("Set EL_KEY env var.")
    manifest, total = [], 0.0
    for seg in SEGMENTS:
        print(f"-> {seg['id']}: {seg['text'][:46]}...", flush=True)
        data = synth(seg["text"], key)
        pcm = base64.b64decode(data["audio_base64"])
        out = OUT_DIR / f"{seg['id']}.wav"
        dur = write_wav(pcm, out)
        align = data.get("alignment") or data.get("normalized_alignment") or {}
        words = chars_to_words(
            seg["text"],
            align.get("characters", []),
            align.get("character_start_times_seconds", []),
            align.get("character_end_times_seconds", []),
        )
        total += dur
        manifest.append({"id": seg["id"], "text": seg["text"], "duration": dur, "file": f"ad-vo/{seg['id']}.wav", "words": words})
        print(f"   {dur}s, {len(words)} words", flush=True)
    (OUT_DIR / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
    print(f"\n{MODEL} | total {round(total, 2)}s / {len(manifest)} segs (word-timed)", flush=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())