Old Tam speaks (Kokoro); CC0 birdsong, crickets and owls; no plants through floors; menu and marker fixes
This commit is contained in:
@@ -6,6 +6,7 @@ third-party audio), so they're ours to release under CC0 too.
|
||||
python3 tools/audio/make_audio.py # everything
|
||||
python3 tools/audio/make_audio.py music # or: music | ambience | sfx | voices
|
||||
python3 tools/audio/make_audio.py --kenney ~/Downloads/kenney # also re-copy the Kenney files
|
||||
python3 tools/audio/make_audio.py birds # or crickets: one recorded loop, from tools/audio/sources
|
||||
|
||||
Writes .ogg files under assets/audio/ (via ffmpeg). Deterministic: the same
|
||||
seeds give the same files. Pure Python (no numpy), so it takes a minute or two.
|
||||
@@ -341,69 +342,6 @@ def track_boss():
|
||||
|
||||
# --- ambience ---------------------------------------------------------------------
|
||||
|
||||
def amb_birds():
|
||||
sr, secs = 22050, 32
|
||||
L = sr * secs
|
||||
rng = random.Random(61)
|
||||
l, r = [0.0] * L, [0.0] * L
|
||||
add_noise(l, sr, 0, secs, 0.06, lp=0.03)
|
||||
add_noise(r, sr, 0, secs, 0.06, lp=0.03, rng=random.Random(5))
|
||||
t = 0.4
|
||||
while t < secs - 1.0:
|
||||
pan = rng.random()
|
||||
base = rng.uniform(2400, 4200)
|
||||
kind = rng.randrange(3)
|
||||
n = rng.randint(2, 6)
|
||||
tt = t
|
||||
for k in range(n):
|
||||
d = rng.uniform(0.05, 0.11)
|
||||
f0 = base * rng.uniform(0.9, 1.15)
|
||||
f1 = f0 * (rng.uniform(0.7, 0.85) if kind == 0 else rng.uniform(1.15, 1.35) if kind == 1 else 1.0)
|
||||
nn = int(d * sr)
|
||||
ph = 0.0
|
||||
a = rng.uniform(0.15, 0.3)
|
||||
for j in range(nn):
|
||||
q = j / nn
|
||||
ph += TAU * (f0 + (f1 - f0) * q) / sr
|
||||
e = sin(math.pi * q) ** 2
|
||||
s = sin(ph) * e * a
|
||||
i = (int(tt * sr) + j) % L
|
||||
l[i] += s * (1 - pan)
|
||||
r[i] += s * pan
|
||||
tt += d + rng.uniform(0.02, 0.09)
|
||||
t += rng.uniform(0.6, 2.6)
|
||||
return sr, (l, r)
|
||||
|
||||
|
||||
def amb_crickets():
|
||||
sr, secs = 22050, 30
|
||||
L = sr * secs
|
||||
rng = random.Random(71)
|
||||
l, r = [0.0] * L, [0.0] * L
|
||||
add_noise(l, sr, 0, secs, 0.04, lp=0.02)
|
||||
add_noise(r, sr, 0, secs, 0.04, lp=0.02, rng=random.Random(9))
|
||||
for c in range(4):
|
||||
pan = rng.random()
|
||||
f = rng.uniform(3800, 4700)
|
||||
period = rng.uniform(0.5, 0.9)
|
||||
pulses = rng.randint(3, 5)
|
||||
amp = rng.uniform(0.06, 0.14)
|
||||
t = rng.random() * period
|
||||
while t < secs:
|
||||
for p in range(pulses):
|
||||
nn = int(0.018 * sr)
|
||||
i0 = int((t + p * 0.032) * sr)
|
||||
for j in range(nn):
|
||||
e = sin(math.pi * j / nn)
|
||||
s = sin(TAU * f * j / sr) * e * amp
|
||||
l[(i0 + j) % L] += s * (1 - pan)
|
||||
r[(i0 + j) % L] += s * pan
|
||||
t += period * rng.uniform(0.95, 1.05)
|
||||
if rng.random() < 0.08:
|
||||
t += rng.uniform(1.0, 3.0) # (they pause now and then)
|
||||
return sr, (l, r)
|
||||
|
||||
|
||||
def amb_fire():
|
||||
sr, secs = 22050, 6
|
||||
L = sr * secs
|
||||
@@ -700,9 +638,80 @@ def make_sfx():
|
||||
def make_ambience():
|
||||
d = os.path.join(OUT, "ambience")
|
||||
print("ambience ->", os.path.relpath(d, ROOT))
|
||||
for name, fn in (("birds", amb_birds), ("crickets", amb_crickets), ("fire", amb_fire)):
|
||||
for name, fn in (("fire", amb_fire),):
|
||||
sr, ch = fn()
|
||||
_encode(os.path.join(d, name + ".ogg"), list(ch), sr, 3)
|
||||
for name in RECORDED:
|
||||
make_recorded(name)
|
||||
|
||||
|
||||
SOURCES = os.path.join(ROOT, "tools", "audio", "sources")
|
||||
|
||||
|
||||
def sources(prefix):
|
||||
"""The CC0 recordings a loop is made from (credited in assets/audio/CREDITS.md)."""
|
||||
if not os.path.isdir(SOURCES):
|
||||
return []
|
||||
return sorted(os.path.join(SOURCES, f) for f in os.listdir(SOURCES) if f.startswith(prefix + "_"))
|
||||
|
||||
|
||||
## The field-recorded loops: name -> (tone filter, which stretch of each source).
|
||||
## Birds: the shrillest top notes pulled back a little (a dawn chorus is bright).
|
||||
## Crickets: the rumble under them (wind, a distant road) taken out.
|
||||
RECORDED = {
|
||||
"birds": ("highshelf=f=3800:g=-5,lowpass=f=8500", None),
|
||||
"crickets": ("highpass=f=300,highshelf=f=6000:g=-3", None),
|
||||
}
|
||||
|
||||
|
||||
def _decode(src, sr, af=None):
|
||||
cmd = ["ffmpeg", "-v", "error", "-i", src] + (["-af", af] if af else []) + \
|
||||
["-f", "s16le", "-ac", "2", "-ar", str(sr), "-"]
|
||||
raw = subprocess.run(cmd, capture_output=True, check=True).stdout
|
||||
n = len(raw) // 4
|
||||
left = [struct.unpack_from("<h", raw, i * 4)[0] / 32768.0 for i in range(n)]
|
||||
right = [struct.unpack_from("<h", raw, i * 4 + 2)[0] / 32768.0 for i in range(n)]
|
||||
# Each to the same loudness (RMS), so the loop doesn't swell and fade.
|
||||
rms = math.sqrt(sum(x * x for x in left + right) / (2 * n)) or 1.0
|
||||
return [x * 0.06 / rms for x in left], [x * 0.06 / rms for x in right]
|
||||
|
||||
|
||||
def make_recorded(name, srcs=None):
|
||||
"""A field-recorded ambience loop (assets/audio/ambience/<name>.ogg) from
|
||||
CC0 recordings (tools/audio/sources/<name>_*, or the files given): each
|
||||
evened out to the same loudness, chained one into the next and the last
|
||||
back into the first with 3 s equal-power crossfades, into one seamless
|
||||
loop. (Synthesised birdsong sounded like beeping, and crickets like a
|
||||
smoke alarm.)"""
|
||||
tone, _ = RECORDED[name]
|
||||
srcs = srcs or sources(name)
|
||||
if not srcs:
|
||||
sys.exit("no recordings for %s in %s" % (name, os.path.relpath(SOURCES, ROOT)))
|
||||
sr, xf = 44100, 3.0
|
||||
k = int(xf * sr)
|
||||
clips = [_decode(s, sr, tone) for s in srcs]
|
||||
out_l, out_r = [], []
|
||||
for c, (left, right) in enumerate(clips):
|
||||
if c == 0:
|
||||
out_l, out_r = list(left), list(right)
|
||||
continue
|
||||
# The end of what we have fades out over the start of the next clip.
|
||||
base = len(out_l) - k
|
||||
for i in range(k):
|
||||
a = math.sin(0.5 * math.pi * i / k)
|
||||
b = math.cos(0.5 * math.pi * i / k)
|
||||
out_l[base + i] = out_l[base + i] * b + left[i] * a
|
||||
out_r[base + i] = out_r[base + i] * b + right[i] * a
|
||||
out_l += left[k:]
|
||||
out_r += right[k:]
|
||||
# And the end back into the beginning, so it loops.
|
||||
L = len(out_l) - k
|
||||
for i in range(k):
|
||||
a = math.sin(0.5 * math.pi * i / k)
|
||||
b = math.cos(0.5 * math.pi * i / k)
|
||||
out_l[i] = out_l[i] * a + out_l[L + i] * b
|
||||
out_r[i] = out_r[i] * a + out_r[L + i] * b
|
||||
_encode(os.path.join(OUT, "ambience", name + ".ogg"), [out_l[:L], out_r[:L]], sr, 4)
|
||||
|
||||
|
||||
def make_music():
|
||||
@@ -773,6 +782,9 @@ if __name__ == "__main__":
|
||||
copy_kenney(os.path.expanduser(args[i + 1]))
|
||||
del args[i:i + 2]
|
||||
what = args[0] if args else "all"
|
||||
if what in RECORDED: # one recorded loop: birds | crickets
|
||||
make_recorded(what)
|
||||
sys.exit(0)
|
||||
if what in ("all", "sfx"):
|
||||
make_sfx()
|
||||
if what in ("all", "sfx", "voices"):
|
||||
|
||||
0
tools/audio/sources/.gdignore
Normal file
0
tools/audio/sources/.gdignore
Normal file
BIN
tools/audio/sources/birds_samsterbirdies_745273.ogg
Normal file
BIN
tools/audio/sources/birds_samsterbirdies_745273.ogg
Normal file
Binary file not shown.
BIN
tools/audio/sources/crickets_owlstorm_320145.mp3
Normal file
BIN
tools/audio/sources/crickets_owlstorm_320145.mp3
Normal file
Binary file not shown.
BIN
tools/audio/sources/owl_breviceps_465697.mp3
Normal file
BIN
tools/audio/sources/owl_breviceps_465697.mp3
Normal file
Binary file not shown.
0
tools/voice/.gdignore
Normal file
0
tools/voice/.gdignore
Normal file
85
tools/voice/make_voice.py
Normal file
85
tools/voice/make_voice.py
Normal 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
21
tools/voice/make_voice.sh
Executable 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 "$@"
|
||||
Reference in New Issue
Block a user