|
#!/usr/bin/env -S uv run --script |
|
# /// script |
|
# requires-python = ">=3.10" |
|
# dependencies = ["google-genai>=0.3"] |
|
# /// |
|
# Converts a markdown file of any length to a single warm, professional WAV |
|
# narration using Gemini TTS (gemini-3.1-flash-tts-preview, Kore voice). |
|
# Usage: md2speech INPUT.md [-o OUTPUT.wav] [--voice Kore] [--model ...] |
|
# Requires `uv` (https://docs.astral.sh/uv/); deps are installed automatically. |
|
import argparse |
|
import os |
|
import re |
|
import sys |
|
import time |
|
import wave |
|
from pathlib import Path |
|
|
|
STYLE_PROMPT = ( |
|
"Read the following in a natural and professional tone, as if " |
|
"narrating a personal audio version of a report to the manager. Use clear " |
|
"pacing, gentle emphasis, and a conversational rhythm." |
|
) |
|
|
|
SAMPLE_RATE = 24000 # default/fallback; actual rate is parsed from the response |
|
SAMPLE_WIDTH = 2 # 16-bit |
|
CHANNELS = 1 |
|
# Gemini TTS audio quality degrades on generations longer than ~60s (voice |
|
# drift, pitch/volume collapse). ~800 chars ≈ ~130 words ≈ under a minute of |
|
# speech, keeping every request inside the healthy window. |
|
CHUNK_TARGET_CHARS = 800 |
|
|
|
def strip_markdown(text: str) -> str: |
|
# Fenced code blocks → spoken cue |
|
text = re.sub(r"```.*?```", ". Code block omitted. ", text, flags=re.DOTALL) |
|
# Indented code blocks (4-space) → spoken cue |
|
text = re.sub(r"(?m)^(?: {4}|\t).*(?:\n(?: {4}|\t).*)*", ". Code block omitted. ", text) |
|
# HTML tags |
|
text = re.sub(r"<[^>]+>", "", text) |
|
# Images → alt text |
|
text = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", text) |
|
# Links → link text |
|
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) |
|
# Inline code → inner text |
|
text = re.sub(r"`([^`]+)`", r"\1", text) |
|
# Headings: strip leading #s |
|
text = re.sub(r"(?m)^[ \t]{0,3}#{1,6}[ \t]*", "", text) |
|
# Blockquote markers |
|
text = re.sub(r"(?m)^[ \t]*>[ \t]?", "", text) |
|
# Horizontal rules |
|
text = re.sub(r"(?m)^[ \t]*([-*_])(?:[ \t]*\1){2,}[ \t]*$", "", text) |
|
# List markers |
|
text = re.sub(r"(?m)^[ \t]*[-*+][ \t]+", "", text) |
|
text = re.sub(r"(?m)^[ \t]*\d+\.[ \t]+", "", text) |
|
# Bold/italic markers (greedy-safe: only paired ** __ * _) |
|
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) |
|
text = re.sub(r"__([^_]+)__", r"\1", text) |
|
text = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"\1", text) |
|
text = re.sub(r"(?<!_)_([^_\n]+)_(?!_)", r"\1", text) |
|
# Collapse runs of blank lines |
|
text = re.sub(r"\n{3,}", "\n\n", text) |
|
return text.strip() |
|
|
|
def split_sentences(paragraph: str) -> list[str]: |
|
parts = re.split(r"(?<=[.!?])\s+", paragraph) |
|
return [p.strip() for p in parts if p.strip()] |
|
|
|
def chunk_text(text: str, limit: int = CHUNK_TARGET_CHARS) -> list[str]: |
|
chunks: list[str] = [] |
|
buf = "" |
|
for para in re.split(r"\n\s*\n", text): |
|
para = para.strip() |
|
if not para: |
|
continue |
|
if len(para) > limit: |
|
if buf: |
|
chunks.append(buf) |
|
buf = "" |
|
sent_buf = "" |
|
for sent in split_sentences(para): |
|
if len(sent) > limit: |
|
# Last resort: hard-wrap on whitespace |
|
if sent_buf: |
|
chunks.append(sent_buf) |
|
sent_buf = "" |
|
words = sent.split(" ") |
|
line = "" |
|
for w in words: |
|
if len(line) + len(w) + 1 > limit and line: |
|
chunks.append(line) |
|
line = w |
|
else: |
|
line = f"{line} {w}".strip() |
|
if line: |
|
sent_buf = line |
|
continue |
|
if len(sent_buf) + len(sent) + 1 > limit: |
|
chunks.append(sent_buf) |
|
sent_buf = sent |
|
else: |
|
sent_buf = f"{sent_buf} {sent}".strip() |
|
if sent_buf: |
|
buf = sent_buf |
|
continue |
|
if len(buf) + len(para) + 2 > limit: |
|
chunks.append(buf) |
|
buf = para |
|
else: |
|
buf = f"{buf}\n\n{para}".strip() |
|
if buf: |
|
chunks.append(buf) |
|
return chunks |
|
|
|
def parse_rate(mime_type: str | None) -> int: |
|
# mime_type looks like "audio/l16;codec=pcm;rate=24000;channels=1" |
|
# (Gemini 3.1 uses lowercase l16; the rate can vary by model). |
|
if mime_type: |
|
m = re.search(r"rate=(\d+)", mime_type, flags=re.IGNORECASE) |
|
if m: |
|
return int(m.group(1)) |
|
return SAMPLE_RATE |
|
|
|
def synthesize(client, model: str, voice: str, text: str) -> tuple[bytes, int]: |
|
from google.genai import types |
|
config = types.GenerateContentConfig( |
|
response_modalities=["AUDIO"], |
|
speech_config=types.SpeechConfig( |
|
voice_config=types.VoiceConfig( |
|
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice), |
|
), |
|
), |
|
) |
|
last_err = None |
|
for attempt in range(3): |
|
try: |
|
resp = client.models.generate_content( |
|
model=model, |
|
contents=f"{STYLE_PROMPT}\n\n{text}", |
|
config=config, |
|
) |
|
part = resp.candidates[0].content.parts[0].inline_data |
|
return part.data, parse_rate(part.mime_type) |
|
except Exception as e: # noqa: BLE001 |
|
last_err = e |
|
wait = 2 ** attempt |
|
print(f" retry {attempt + 1}/3 after {wait}s: {e}", file=sys.stderr) |
|
time.sleep(wait) |
|
raise RuntimeError(f"TTS failed after 3 attempts: {last_err}") |
|
|
|
def write_wav(path: Path, pcm: bytes, rate: int) -> None: |
|
with wave.open(str(path), "wb") as w: |
|
w.setnchannels(CHANNELS) |
|
w.setsampwidth(SAMPLE_WIDTH) |
|
w.setframerate(rate) |
|
w.writeframes(pcm) |
|
|
|
def main() -> int: |
|
p = argparse.ArgumentParser(description="Markdown → warm/professional WAV narration via Gemini TTS.") |
|
p.add_argument("input", type=Path, help="Markdown file to narrate") |
|
p.add_argument("-o", "--output", type=Path, help="Output WAV path (default: <input>.wav in cwd)") |
|
p.add_argument("--voice", default="Kore", help="Prebuilt voice name (default: Kore)") |
|
p.add_argument("--model", default="gemini-3.1-flash-tts-preview", help="Gemini TTS model") |
|
args = p.parse_args() |
|
|
|
if not args.input.exists(): |
|
print(f"error: {args.input} not found", file=sys.stderr) |
|
return 1 |
|
if not os.environ.get("GEMINI_API_KEY"): |
|
print("error: GEMINI_API_KEY is not set", file=sys.stderr) |
|
return 1 |
|
|
|
try: |
|
from google import genai # type: ignore |
|
except ImportError: |
|
print("error: google-genai unavailable. This script expects to be run via " |
|
"the `uv run --script` shebang; install uv from https://docs.astral.sh/uv/", file=sys.stderr) |
|
return 1 |
|
|
|
raw = args.input.read_text(encoding="utf-8") |
|
spoken = strip_markdown(raw) |
|
if not spoken: |
|
print("error: input has no narratable text after markdown stripping", file=sys.stderr) |
|
return 1 |
|
|
|
chunks = chunk_text(spoken) |
|
out_path = args.output or Path.cwd() / f"{args.input.stem}.wav" |
|
|
|
print(f"narrating {args.input.name} → {out_path.name} " |
|
f"({len(spoken)} chars, {len(chunks)} chunk{'s' if len(chunks) != 1 else ''}, " |
|
f"voice={args.voice})", file=sys.stderr) |
|
|
|
client = genai.Client() |
|
pcm = bytearray() |
|
rate = SAMPLE_RATE |
|
for i, chunk in enumerate(chunks, 1): |
|
print(f"[{i}/{len(chunks)}] synthesizing {len(chunk)} chars…", file=sys.stderr) |
|
data, chunk_rate = synthesize(client, args.model, args.voice, chunk) |
|
if i == 1: |
|
rate = chunk_rate |
|
elif chunk_rate != rate: |
|
print(f" warning: chunk {i} sample rate {chunk_rate} != {rate}; " |
|
f"audio may be distorted", file=sys.stderr) |
|
pcm.extend(data) |
|
|
|
write_wav(out_path, bytes(pcm), rate) |
|
secs = len(pcm) / (rate * SAMPLE_WIDTH * CHANNELS) |
|
print(f"done: {out_path} ({secs:.1f}s, {len(pcm) / 1024:.0f} KiB)", file=sys.stderr) |
|
return 0 |
|
|
|
if __name__ == "__main__": |
|
sys.exit(main()) |