Old Tam rewritten and re-voiced (Gitea #3): a new script in his own voice, an old West Country farmer (Qwen3-TTS cloning one recording, so he's the same man in every line), each line's best take chosen by checking its words with Whisper

This commit is contained in:
Paul Bailey
2026-11-08 23:35:18 +00:00
parent 4a691b72e1
commit a3660f91cf
54 changed files with 220 additions and 173 deletions

View File

@@ -1,23 +1,33 @@
"""Old Tam's voice: every line he says (the tutorial's steps and goodbye in
data/tutorial.json, his tips in data/hints.json), spoken by Kokoro (Kokoro-82M,
Apache-2.0, hexgrad) with its British "George" voice, then made a little older:
deeper, slower and warmer. Output: assets/audio/voice/<line id>.ogg plus
lines.json, the text each file was made from (the game only plays a file
whose text still matches, so an edited line goes quiet rather than saying
the old words; tests/test_tutorial.gd checks they're all up to date).
data/tutorial.json, his tips in data/hints.json), in his own voice: Qwen3-TTS
(Alibaba's Qwen team, Apache-2.0) cloning tools/voice/old_tam/reference.wav,
a reedy old West Country farmer that Qwen3's VoiceDesign made from a written
description (old_tam/voice.json). Cloning that one recording, rather than
describing him again for each line, keeps him the same man throughout.
Run it with tools/voice/make_voice.sh (sets up the Kokoro environment once).
Only lines whose text changed are re-made; --all re-makes everything.
Each line is recorded TAKES times; Whisper (mlx-whisper) writes down what
each take says, and the take whose words best match the line is kept (a take
that drops or garbles words loses). Output: assets/audio/voice/<line id>.ogg,
lines.json (the written text each file was made from: the game only plays a
file whose text still matches, so an edited line goes quiet rather than
saying the old words; tests/test_tutorial.gd checks they're all up to date)
and spoken.json (what each one says).
Run it with tools/voice/make_voice.sh (sets up the environment once, on an
Apple-silicon Mac: MLX). Only lines whose text, or how they're spoken, changed
are re-made; --all re-makes everything; --takes N for more takes a line.
"""
import hashlib, json, os, re, subprocess, sys, tempfile
import difflib, hashlib, json, os, re, subprocess, sys, tempfile
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
OUT = os.path.join(ROOT, "assets", "audio", "voice")
VOICE = "bm_george"
SPEED = 0.9
# Older: down about 1.4 semitones (and so a touch slower), then a little
# warmth and the top taken off.
AGE = "asetrate=24000*0.92,aresample=24000,atempo=1.04,lowpass=f=6500,bass=g=2:f=180"
HERE = os.path.dirname(os.path.abspath(__file__))
VOICE = json.load(open(os.path.join(HERE, "old_tam", "voice.json")))
MODEL = VOICE["model"]
REF_AUDIO = os.path.join(HERE, "old_tam", VOICE["reference_audio"])
REF_TEXT = VOICE["reference_text"]
WHISPER = "mlx-community/whisper-large-v3-turbo"
TAKES = 3
def lines():
@@ -45,19 +55,24 @@ def spoken(text):
s = s.replace(" · ", ", ").replace("&", "and")
s = re.sub(r"\bLMB\b", "left click", s)
s = re.sub(r"\bRMB\b", "right click", s)
s = re.sub(r"\bWASD\b", "W, A, S and D", s) # (Gitea #3: it said "Wasad")
# (Gitea #3: spelt out, "W, A, S and D" comes out rushed; the screen shows the keys.)
s = re.sub(r"\bWASD\b", "the keys", s)
s = re.sub(r"\s+,", ",", s)
return s
def _changed_by_new_rules(text):
"""Whether a line has something the spoken() rules added since the last
full recording (0.6.2): "WASD"."""
return re.search(r"\bWASD\b", text) is not None
def _words(s):
return re.sub(r"[^a-z' ]", " ", s.lower().replace("-", " ")).split()
def match(want, heard):
"""How closely what Whisper heard matches the line (0..1)."""
return difflib.SequenceMatcher(None, _words(want), _words(heard)).ratio()
def main():
redo_all = "--all" in sys.argv
takes = int(sys.argv[sys.argv.index("--takes") + 1]) if "--takes" in sys.argv else TAKES
os.makedirs(OUT, exist_ok=True)
manifest_path = os.path.join(OUT, "lines.json")
old = json.load(open(manifest_path)) if os.path.exists(manifest_path) else {}
@@ -66,38 +81,57 @@ def main():
# key: that's what the game checks against the screen.)
said_path = os.path.join(OUT, "spoken.json")
said = json.load(open(said_path)) if os.path.exists(said_path) else {}
voice_key = key(json.dumps(VOICE, sort_keys=True))
new_voice = said.get("_voice") != voice_key
todo = [(i, t) for i, t in lines()
if redo_all or old.get(i) != key(t) or said.get(i, key(spoken(t))) != key(spoken(t))
if redo_all or new_voice or old.get(i) != key(t) or said.get(i) != key(spoken(t))
or not os.path.exists(os.path.join(OUT, i + ".ogg"))]
if not said:
# (First run with spoken.json: lines spoken differently now than when
# they were recorded can't be told apart, so re-record the ones whose
# spoken form isn't just their text.)
todo += [(i, t) for i, t in lines() if spoken(t) != t and (i, t) not in todo and _changed_by_new_rules(t)]
keep = {i for i, _ in lines()}
for f in os.listdir(OUT): # lines that no longer exist
if f.endswith(".ogg") and f[:-4] not in keep:
os.remove(os.path.join(OUT, f))
report = []
if todo:
import numpy as np
import soundfile as sf
from kokoro import KPipeline
pipe = KPipeline(lang_code="b", repo_id="hexgrad/Kokoro-82M")
for i, text in todo:
audio = np.concatenate([a for _, _, a in pipe(spoken(text), voice=VOICE, speed=SPEED)])
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
sf.write(tmp, audio, 24000)
import mlx_whisper
from mlx_audio.tts.utils import load_model
model = load_model(MODEL)
sr = getattr(model, "sample_rate", 24000)
tmpdir = tempfile.mkdtemp()
for n, (i, text) in enumerate(todo, 1):
say = spoken(text)
best = None
for k in range(takes):
res = list(model.generate(text=say, ref_audio=REF_AUDIO, ref_text=REF_TEXT))
audio = np.concatenate([np.array(r.audio) for r in res])
wav = os.path.join(tmpdir, "%s_%d.wav" % (i, k))
sf.write(wav, audio, sr)
heard = mlx_whisper.transcribe(wav, path_or_hf_repo=WHISPER, language="en")["text"].strip()
score = match(say, heard)
if best is None or score > best[0]:
best = (score, wav, heard, len(audio) / sr)
if score >= 0.98:
break # (word for word: no need for another)
score, wav, heard, secs = best
path = os.path.join(OUT, i + ".ogg")
# (ffmpeg's own Vorbis encoder only writes stereo)
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", tmp, "-af", AGE + ",apad=pad_dur=0.25", "-ac", "2",
"-c:a", "vorbis", "-strict", "-2", "-q:a", "4", path], check=True)
os.remove(tmp)
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", wav, "-af", "apad=pad_dur=0.25", "-ac", "2",
"-c:a", "vorbis", "-strict", "-2", "-q:a", "5", path], check=True)
old[i] = key(text)
print(" %s (%.1f s)" % (i, len(audio) / 24000 / 0.92 / 1.04))
said[i] = key(say)
flag = "" if score >= 0.9 else " <-- check this one: heard \"%s\"" % heard
report.append((score, i, heard))
print(" [%d/%d] %s (%.1f s, words %.0f%%)%s" % (n, len(todo), i, secs, score * 100, flag))
manifest = {i: key(t) for i, t in lines()}
json.dump(manifest, open(manifest_path, "w"), indent="\t", sort_keys=True)
json.dump({i: key(spoken(t)) for i, t in lines()}, open(said_path, "w"), indent="\t", sort_keys=True)
out_said = {i: said.get(i, key(spoken(t))) for i, t in lines()}
out_said["_voice"] = voice_key
json.dump(out_said, open(said_path, "w"), indent="\t", sort_keys=True)
print("%d lines, %d made now -> %s" % (len(manifest), len(todo), os.path.relpath(OUT, ROOT)))
weak = [r for r in report if r[0] < 0.9]
if weak:
print("%d to listen to (the words didn't quite match): %s" % (len(weak), ", ".join(r[1] for r in weak)))
if __name__ == "__main__":