Old Tam speaks (Kokoro); CC0 birdsong, crickets and owls; no plants through floors; menu and marker fixes

This commit is contained in:
Paul Bailey
2026-11-06 00:03:59 +00:00
parent 0550a4daa0
commit 787d8d1d65
70 changed files with 495 additions and 87 deletions

0
tools/voice/.gdignore Normal file
View File

85
tools/voice/make_voice.py Normal file
View File

@@ -0,0 +1,85 @@
"""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).
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.
"""
import 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"
def lines():
"""[(id, text)] for everything Old Tam says."""
out = []
t = json.load(open(os.path.join(ROOT, "data", "tutorial.json")))
for s in t["steps"]:
out.append(("tutorial_" + s["id"], s["text"]))
if t.get("finish", {}).get("text"):
out.append(("tutorial_finish", t["finish"]["text"]))
h = json.load(open(os.path.join(ROOT, "data", "hints.json")))
for x in h["hints"]:
out.append(("hint_" + x["id"], x["text"]))
return out
def key(text):
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:16]
def spoken(text):
"""What to say for what's written: keys read as words, no brackets."""
s = text.replace("(E)", "with E").replace("[E]", "E")
s = re.sub(r"\(([^)]*)\)", r", \1,", s) # asides become pauses
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"\s+,", ",", s)
return s
def main():
redo_all = "--all" in sys.argv
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 {}
todo = [(i, t) for i, t in lines()
if redo_all or old.get(i) != key(t) or not os.path.exists(os.path.join(OUT, i + ".ogg"))]
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))
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)
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)
old[i] = key(text)
print(" %s (%.1f s)" % (i, len(audio) / 24000 / 0.92 / 1.04))
manifest = {i: key(t) for i, t in lines()}
json.dump(manifest, open(manifest_path, "w"), indent="\t", sort_keys=True)
print("%d lines, %d made now -> %s" % (len(manifest), len(todo), os.path.relpath(OUT, ROOT)))
if __name__ == "__main__":
main()

21
tools/voice/make_voice.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/sh
# Old Tam's voice (tools/voice/make_voice.py). Run after changing any line in
# data/tutorial.json or data/hints.json:
#
# tools/voice/make_voice.sh # just the lines that changed
# tools/voice/make_voice.sh --all # every line again
#
# The first run sets up a Python 3.12 environment for Kokoro in
# tools/voice/.venv (about 1 GB, with PyTorch; needs uv) and downloads the
# Kokoro model (about 330 MB) from Hugging Face.
cd "$(dirname "$0")/../.." || exit 1
V=tools/voice/.venv
command -v ffmpeg >/dev/null || { echo "needs ffmpeg (brew install ffmpeg)"; exit 1; }
if [ ! -x "$V/bin/python" ]; then
command -v uv >/dev/null || { echo "needs uv (https://docs.astral.sh/uv/)"; exit 1; }
uv venv -q --python 3.12 "$V" || exit 1
uv pip install -q --python "$V/bin/python" "kokoro==0.9.4" "misaki[en]==0.9.4" "soundfile==0.14.0" \
"en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" || exit 1
fi
exec "$V/bin/python" tools/voice/make_voice.py "$@"