"""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/.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()