Skip to content

Instantly share code, notes, and snippets.

@cubny
Last active June 10, 2026 08:54
Show Gist options
  • Select an option

  • Save cubny/70e26b6a907dd487b4819de0c6cd13bb to your computer and use it in GitHub Desktop.

Select an option

Save cubny/70e26b6a907dd487b4819de0c6cd13bb to your computer and use it in GitHub Desktop.
Markdown to Speech

Setting up md2speech on a Mac

You'll need about 5 minutes and a Gemini API key.

1. Open the Terminal

Press ⌘ + Space, type Terminal, hit Enter. A black/white window opens — that's where you'll paste commands.

2. Install uv (runs the script)

Paste this and press Enter:

curl -LsSf https://astral.sh/uv/install.sh | sh

When it finishes, close the Terminal window and open a new one so the change takes effect.

3. Get a Gemini API key

  1. Go to https://aistudio.google.com/apikey
  2. Sign in with a Google account
  3. Click Create API key and copy the long string it gives you

4. Save the key so md2speech can find it

In Terminal, paste this — but replace PASTE_KEY_HERE with the key you just copied:

echo 'export GEMINI_API_KEY="PASTE_KEY_HERE"' >> ~/.zshrc

Then close and reopen Terminal one more time.

5. Make the script runnable

The script lives at ~/.local/bin/md2speech. Make it executable and make sure that folder is on your PATH:

chmod +x ~/.local/bin/md2speech
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc

Close and reopen Terminal.

6. Try it

Put a markdown file (e.g. report.md) on your Desktop, then run:

cd ~/Desktop
md2speech report.md

After a moment you'll get report.wav next to it — double-click to play.

Tip: to pick a different output name, add -o my_narration.wav at the end.

If something goes wrong

  • command not found: md2speech → you skipped step 5, or didn't reopen Terminal
  • GEMINI_API_KEY is not set → step 4 didn't take; reopen Terminal and try again
  • command not found: uv → step 2 didn't finish; rerun the install command
#!/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())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment