#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["numpy"]
# ///
"""A free draft take in the computer's own voice, with word times that match it.
narrate.py --scratch uses this when there is no ElevenLabs key or no credits to spend:
engine = Engine.find(name=None, voice=None, rate=150, language="en")
words = engine.take("Every frame is drawn in code.", Path("audio/s01.mp3"))
Engines: macOS `say`; on Linux, `espeak-ng` or the older `espeak`.
The word times come from a forced alignment, not a guess. The same voice reads the script a
second time with a silence after every word, so each word's sound is known exactly in that
reference. Dynamic time warping over log-mel spectra then maps the reference onto the real
take frame by frame, and each word's edges come with it. Against the engines' own word
markers, word starts land within about 25 ms (median) and 50 ms (90th percentile).
"""
import hashlib
import html
import math
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
import numpy as np
SR = 16000 # analysis sample rate
HOP, WIN = 160, 400 # 10 ms frames over 25 ms windows
APART_MS = 300 # the silence after each word in the reference reading
MAX_CELLS = 2e7 # warping-grid cells (80 MB); longer takes are warped at a coarser step
PAUSE_FRAMES = 8 # a silence longer than 80 ms is a pause between words, not a stop consonant
SNAP_FRAMES = 5 # at most this much of a word on the far side of a pause is a warping slip
# Voices that ship with macOS, by language. Others fall back to the first voice `say` lists.
SAY_VOICES = {"en": ["Samantha", "Alex", "Daniel", "Karen", "Moira"], "es": ["Mónica", "Paulina"],
"fr": ["Thomas", "Amélie"], "de": ["Anna"], "it": ["Alice"], "pt": ["Luciana", "Joana"],
"ja": ["Kyoko"], "zh": ["Tingting", "Meijia"], "ko": ["Yuna"]}
NO_ENGINE = ("narrate.py --scratch needs a speech engine on this computer: `say` (built into macOS), "
"or espeak-ng or espeak on Linux (sudo apt-get install espeak-ng). Neither was found.")
def spoken(token: str) -> bool:
"""A token with letters or digits. A lone dash or symbol makes no sound of its own."""
return any(c.isalnum() for c in token)
@dataclass
class Engine:
name: str # say | espeak-ng | espeak
voice: str | None # None: the engine's default voice
rate: int # words per minute
@classmethod
def find(cls, name: str | None, voice: str | None, rate: int, language: str = "en") -> "Engine":
if name and not shutil.which(name):
raise SystemExit(f"--scratch-engine {name}: not found. {NO_ENGINE}")
if not name:
# Some Linux desktops ship an unrelated `say` (GNUstep), so `say` counts only on macOS.
found = [e for e in ("say", "espeak-ng", "espeak") if shutil.which(e) and (e != "say" or sys.platform == "darwin")]
if not found:
raise SystemExit(NO_ENGINE)
name = found[0]
return cls(name, voice or default_voice(name, language), rate)
@property
def label(self) -> str:
return f"{self.name} {self.voice or 'default voice'}, {self.rate} wpm"
def digest(self, text: str) -> str:
return hashlib.sha256(f"scratch|{self.name}|{self.voice}|{self.rate}|{text}".encode()).hexdigest()[:16]
def command(self, text: str, out: Path, apart: bool = False) -> list[str]:
"""The command that reads `text` into an AIFF (say) or WAV (espeak) file.
apart=True pauses after every word."""
groups = word_groups(text.split())
src = out.with_suffix(".txt")
if self.name == "say":
src.write_text(f" [[slnc {APART_MS}]] ".join(" ".join(g) for g in groups) if apart else text)
cmd = ["say", "-r", str(self.rate), "-o", str(out), "-f", str(src)]
else:
if apart:
pause = f' '
src.write_text("" + pause.join(html.escape(" ".join(g)) for g in groups) + "")
else:
src.write_text(text)
cmd = [self.name, "-s", str(self.rate), "-w", str(out), "-f", str(src)] + (["-m"] if apart else [])
return cmd[:1] + (["-v", self.voice] if self.voice else []) + cmd[1:]
def take(self, text: str, mp3: Path) -> list[dict]:
"""Record `text` to `mp3` and return its word times, in timings.json's shape."""
ext = ".aiff" if self.name == "say" else ".wav"
with tempfile.TemporaryDirectory() as tmp:
take, apart = Path(tmp) / f"take{ext}", Path(tmp) / f"apart{ext}"
runs = [(cmd, subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True))
for cmd in (self.command(text, take), self.command(text, apart, apart=True))]
for (cmd, proc), out in zip(runs, (take, apart)): # both readings run at once
err = proc.communicate()[1]
if proc.returncode or not out.exists():
raise SystemExit(f"{self.name} failed ({' '.join(cmd)}): {err.strip() or 'no audio written'}")
words = align(decode(take), decode(apart), text.split())
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(take), "-ar", "44100", "-ac", "1",
"-b:a", "192k", str(mp3)], check=True)
return words
def default_voice(engine: str, language: str) -> str | None:
if engine != "say":
return "en-us" if language == "en" else language
listing = subprocess.run(["say", "-v", "?"], capture_output=True, text=True).stdout
voices = re.findall(r"^(.*\S)\s+([a-z]{2,3})_\w+\s+#", listing, re.M)
names = [n for n, lang in voices if lang == language] # "Samantha (English (US))", "Karen", ...
plain = {re.sub(r"\s*\(.*\)$", "", n) for n in names}
return next((v for v in SAY_VOICES.get(language, []) if v in plain), names[0] if names else None)
def word_groups(tokens: list[str]) -> list[list[str]]:
"""Spoken tokens, each with the silent tokens ("—") that follow it. Leading silent tokens are dropped."""
groups: list[list[str]] = []
for t in tokens:
if spoken(t):
groups.append([t])
elif groups:
groups[-1].append(t)
return groups
def decode(path: Path) -> np.ndarray:
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(path), "-ac", "1", "-ar", str(SR), "-f", "f32le", "-"],
capture_output=True, check=True).stdout
return np.frombuffer(raw, dtype=np.float32)
def mel_bank(bands: int = 26, n_fft: int = 512, lo: float = 80.0, hi: float = 7600.0) -> np.ndarray:
mel = np.linspace(2595 * np.log10(1 + lo / 700), 2595 * np.log10(1 + hi / 700), bands + 2)
bins = np.floor((n_fft + 1) * 700 * (10 ** (mel / 2595) - 1) / SR).astype(int)
bank = np.zeros((bands, n_fft // 2 + 1))
for k in range(bands):
a, b, c = bins[k:k + 3]
bank[k, a:b] = (np.arange(a, b) - a) / max(1, b - a)
bank[k, b:c] = (c - np.arange(b, c)) / max(1, c - b)
return bank
MEL = mel_bank()
def analyze(x: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Per 10 ms frame: whether it has sound, and its log-mel spectrum."""
frames = np.lib.stride_tricks.sliding_window_view(np.pad(x, (0, WIN)), WIN)[::HOP]
level = 10 * np.log10(np.mean(frames.astype(np.float64) ** 2, axis=1) + 1e-12)
# Synthetic speech pauses are near digital silence; stop closures inside words stay well above this.
sound = level > max(np.percentile(level, 2) + 12, np.percentile(level, 95) - 45)
power = np.abs(np.fft.rfft(frames * np.hanning(WIN), 512, axis=1)) ** 2
return sound, np.log(power @ MEL.T + 1e-8)
def word_spans(sound: np.ndarray, weights: list[int]) -> list[tuple[int, int]]:
"""Frame ranges of the words in the reading with a pause after each: split at its longest silences."""
count = len(weights)
edges = np.flatnonzero(np.diff(np.r_[0, sound.astype(np.int8), 0]))
on, off = edges[::2], edges[1::2]
if len(on) >= count:
cuts = np.sort(np.argsort(on[1:] - off[:-1], kind="stable")[len(on) - count:])
return [(on[a], off[b]) for a, b in zip(np.r_[0, cuts + 1], np.r_[cuts, len(on) - 1])]
# Two words ran together (rare): share the sound by letters instead.
frames = np.flatnonzero(sound)
bounds = np.round(np.cumsum([0, *weights]) / sum(weights) * len(frames)).astype(int)
return [(frames[a], frames[max(a, b - 1)] + 1) for a, b in zip(bounds, bounds[1:])]
def warp(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Dynamic time warping: the cheapest monotonic path through frame pairs, as (i, j) rows."""
a = a - a.mean(axis=0)
b = b - b.mean(axis=0)
a /= np.linalg.norm(a, axis=1, keepdims=True) + 1e-9
b /= np.linalg.norm(b, axis=1, keepdims=True) + 1e-9
n, m = len(a), len(b)
cost = np.full((n + 1, m + 1), np.inf, dtype=np.float32)
cost[0, 0] = 0
for k in range(2, n + m + 1): # one anti-diagonal at a time, so each step is a single numpy call
i = np.arange(max(1, k - m), min(n, k - 1) + 1)
j = k - i
d = 1 - np.einsum("ij,ij->i", a[i - 1], b[j - 1]) # cosine distance
cost[i, j] = d + np.minimum(np.minimum(cost[i - 1, j - 1], cost[i - 1, j]), cost[i, j - 1])
path, i, j = [], n, m
while i > 0 and j > 0:
path.append((i - 1, j - 1))
step = int(np.argmin((cost[i - 1, j - 1], cost[i - 1, j], cost[i, j - 1])))
i, j = (i - 1, j - 1) if step == 0 else (i - 1, j) if step == 1 else (i, j - 1)
return np.array(path[::-1])
def align(take: np.ndarray, apart: np.ndarray, tokens: list[str]) -> list[dict]:
"""Word times in `take` (16 kHz samples), found by warping the word-by-word reading onto it."""
groups = word_groups(tokens)
if not groups:
return [{"word": t, "start": 0.0, "end": 0.0} for t in tokens]
sound_t, mel_t = analyze(take)
sound_r, mel_r = analyze(apart)
vt, vr = np.flatnonzero(sound_t), np.flatnonzero(sound_r)
if not len(vt) or not len(vr):
raise SystemExit("the scratch voice wrote silence; try another --scratch-voice")
spans = word_spans(sound_r, [sum(c.isalnum() for c in " ".join(g)) or 1 for g in groups])
step = max(1, math.ceil(math.sqrt(len(vt) * len(vr) / MAX_CELLS)))
path = warp(mel_t[vt][::step], mel_r[vr][::step]) * step
# For each reference frame with sound, the first and last take frame the path pairs with it.
first = np.full(len(vr), len(vt) - 1)
last = np.zeros(len(vr), dtype=int)
np.minimum.at(first, np.minimum(path[:, 1], len(vr) - 1), np.minimum(path[:, 0], len(vt) - 1))
np.maximum.at(last, np.minimum(path[:, 1], len(vr) - 1), np.minimum(path[:, 0], len(vt) - 1))
first = np.minimum.accumulate(first[::-1])[::-1] # coarse steps leave gaps; fill them in order
last = np.maximum.accumulate(last)
# The warp sees only frames with sound, so it can slip a frame or two across a pause, which
# moves a word's edge by the whole pause. A sliver of a word on the far side of a pause is
# that slip: the word starts after the pause, or ends before it.
resume = np.flatnonzero(np.diff(vt) > PAUSE_FRAMES) + 1 # first frame after each pause
def seconds(frame: int) -> float:
return (frame * HOP + WIN / 2) / SR
end_of_take = len(take) / SR
times = []
for f0, f1 in spans:
r0 = min(int(np.searchsorted(vr, f0)), len(vr) - 1)
r1 = max(int(np.searchsorted(vr, f1)) - 1, r0)
start, end = int(first[r0]), int(last[r1])
for q in resume[(resume > start) & (resume <= end)]:
before, after = q - start, end - q + 1
if before <= SNAP_FRAMES and before < after:
start = q
elif after <= SNAP_FRAMES and after < before:
end = q - 1
times.append([seconds(vt[start]), min(seconds(vt[end]) + HOP / SR, end_of_take)])
for k in range(len(times)): # words never overlap and never run backwards
if k:
times[k][0] = max(times[k][0], times[k - 1][1])
if k + 1 < len(times):
times[k][1] = min(times[k][1], times[k + 1][0])
times[k][1] = max(times[k][1], times[k][0])
lead = tokens[:len(tokens) - sum(map(len, groups))]
words = [{"word": t, "start": round(times[0][0], 3), "end": round(times[0][0], 3)} for t in lead]
for (start, end), group in zip(times, groups):
words.append({"word": group[0], "start": round(start, 3), "end": round(end, 3)})
words += [{"word": t, "start": round(end, 3), "end": round(end, 3)} for t in group[1:]]
return words