The trailer narrated by Old Tam: a line over every shot (welcome, survive, build, the wolves, winter, the cliffs, the Cove, play with friends), the game's sound dipping under him

This commit is contained in:
Paul Bailey
2026-11-09 00:45:40 +00:00
parent e5e7c0e343
commit 47966e464d
14 changed files with 129 additions and 34 deletions

View File

@@ -28,18 +28,40 @@ OUT_AT=$(echo "$D - 2.5" | bc)
# A slow A-minor-ish drone, left and right a little apart, echoed and soft.
PAD="0.06*(sin(2*PI*110*t)+0.7*sin(2*PI*164.8*t)+0.5*sin(2*PI*220*t)+0.35*sin(2*PI*261.6*t)*(0.5+0.5*sin(2*PI*0.08*t)))*(0.75+0.25*sin(2*PI*0.21*t))"
PAD_R="0.06*(sin(2*PI*110.4*t)+0.7*sin(2*PI*165.2*t)+0.5*sin(2*PI*219.6*t)+0.35*sin(2*PI*262*t)*(0.5+0.5*cos(2*PI*0.08*t)))*(0.75+0.25*cos(2*PI*0.21*t))"
# Old Tam (0.6.2: tools/trailer/voice, made by make_trailer_voice.py), each
# line a moment into its shot: the dawn flyover (shot 0), the wolves (3) and
# the Cove (6). The shots' start times are in the render log.
at() { awk -v n="$1" -v d="$2" '$1 == "[shot]" && $2 == n { printf "%d", ($3 + d) * 1000 }' build/trailer/render.log; }
# Old Tam narrates (0.6.2: tools/trailer/voice, made by make_trailer_voice.py):
# each line `at` seconds into its shot, by the shots' start times in the
# render log. The game's sound and the music dip under him.
V=tools/trailer/voice
T_DAWN=$(at 0 2.2); T_WOLVES=$(at 3 1.0); T_COVE=$(at 6 1.2)
[ -n "$T_DAWN" ] && [ -n "$T_WOLVES" ] && [ -n "$T_COVE" ] || { echo "no shot times in build/trailer/render.log"; exit 1; }
ffmpeg -v error -y -i "$RAW" -f lavfi -i "aevalsrc='$PAD|$PAD_R':s=48000:d=$D" \
-i "$V/dawn.ogg" -i "$V/wolves.ogg" -i "$V/cove.ogg" \
NARR=$(python3 - "$V/lines.json" build/trailer/render.log <<'EOF'
import json, sys
lines = json.load(open(sys.argv[1]))
starts = {}
for l in open(sys.argv[2]):
p = l.split()
if len(p) == 3 and p[0] == "[shot]":
starts[int(p[1])] = float(p[2])
inputs, chains, labels = [], [], []
for n, x in enumerate(lines):
if x["shot"] not in starts:
sys.exit("no start time for shot %d in the render log" % x["shot"])
ms = int((starts[x["shot"]] + x["at"]) * 1000)
inputs.append("%s/%s" % (sys.argv[1].rsplit("/", 1)[0], x["file"]))
chains.append("[%d:a]aresample=48000,adelay=%d|%d,volume=1.7[n%d]" % (n + 2, ms, ms, n))
labels.append("[n%d]" % n)
print("\t".join(inputs))
print(";".join(chains) + ";" + "".join(labels) + "amix=inputs=%d:duration=longest:normalize=0[v]" % len(labels))
EOF
) || exit 1
NARR_IN=$(printf '%s\n' "$NARR" | head -1)
NARR_FX=$(printf '%s\n' "$NARR" | tail -1)
set --
OLDIFS=$IFS; IFS=' '
for f in $NARR_IN; do set -- "$@" -i "$f"; done
IFS=$OLDIFS
ffmpeg -v error -y -i "$RAW" -f lavfi -i "aevalsrc='$PAD|$PAD_R':s=48000:d=$D" "$@" \
-filter_complex "[1:a]lowpass=f=1800,aecho=0.8:0.7:180|340:0.35|0.25,afade=t=in:d=2.5,afade=t=out:st=$OUT_AT:d=2.5[m];[0:a]volume=0.8[g];\
[2:a]aresample=48000,adelay=$T_DAWN|$T_DAWN,volume=1.6[v0];[3:a]aresample=48000,adelay=$T_WOLVES|$T_WOLVES,volume=1.6[v1];[4:a]aresample=48000,adelay=$T_COVE|$T_COVE,volume=1.6[v2];\
[v0][v1][v2]amix=inputs=3:duration=longest:normalize=0[v];[g][m][v]amix=inputs=3:duration=first:normalize=0[a]" \
$NARR_FX;[v]asplit=2[v1][vk];[g][m]amix=inputs=2:duration=first:normalize=0[bed];\
[bed][vk]sidechaincompress=threshold=0.02:ratio=6:attack=40:release=500[ducked];[ducked][v1]amix=inputs=2:duration=first:normalize=0[a]" \
-map 0:v -map "[a]" -vf "scale=in_range=pc:out_range=tv,format=yuv420p" -color_range tv \
-colorspace bt709 -color_primaries bt709 -color_trc bt709 \
-c:v libx264 -profile:v high -level 4.0 -preset slow -crf 23 -maxrate 3500k -bufsize 7000k -tag:v avc1 \

View File

@@ -1,7 +1,10 @@
"""Old Tam's lines for the trailer (tools/trailer/make_trailer.sh mixes them
in): short ones, in his voice (the same clone as tools/voice/make_voice.py),
each checked by Whisper and the best of three takes kept. Output:
tools/trailer/voice/<name>.ogg. Run with the voice environment:
"""Old Tam narrates the trailer (tools/trailer/make_trailer.sh mixes it in):
one line for each shot, in his voice (the same clone as
tools/voice/make_voice.py), each checked by Whisper, the best of three takes
kept, and sped up a touch if it would run past the start of the next line.
Output: tools/trailer/voice/<NN>.ogg and lines.json ([{file, shot, at, text}]:
`at` is seconds into its shot; make_trailer.sh reads the shots' start times
from the render log). Run with the voice environment:
tools/voice/.venv-qwen/bin/python tools/trailer/make_trailer_voice.py
"""
import json, os, subprocess, sys, tempfile
@@ -11,12 +14,20 @@ sys.path.insert(0, os.path.join(HERE, "..", "voice"))
import make_voice as mv # (its voice, its checks)
OUT = os.path.join(HERE, "voice")
## name -> what he says (make_trailer.sh times each against the shots).
LINES = {
"dawn": "Ha! Awake at last! Washed up on my island, eh?",
"wolves": "Hush now. See it, there, at the edge of the light?",
"cove": "Well, you made it down!",
}
## The shots (trailer.gd), their lengths in seconds, and what he says over
## each, starting `at` seconds in.
SHOTS = [7.5, 5.0, 6.0, 5.5, 4.5, 5.5, 6.0, 6.0]
LINES = [
(0, 2.0, "Welcome to Cinderwild. You wake on a wild island, washed up with nothing but the clothes on your back."),
(1, 0.3, "Gather what you can. Hunt, forage, and cook over an open fire."),
(2, 0.3, "Build yourself a home, plank by plank, before the nights draw in."),
(3, 0.3, "Because when the sun goes down, the wolves come out. Keep your fire burning."),
(4, 0.3, "Weather the storms, and the hard winters."),
(5, 0.3, "Then dig down, through the sea cliffs."),
(6, 0.3, "And find out what lies beyond."),
(7, 0.6, "Play alone, or with your friends, on your own server. Cinderwild."),
]
GAP = 0.25 # seconds of quiet before the next line
def main():
@@ -25,26 +36,43 @@ def main():
import mlx_whisper
from mlx_audio.tts.utils import load_model
os.makedirs(OUT, exist_ok=True)
for f in os.listdir(OUT):
if f.endswith(".ogg"):
os.remove(os.path.join(OUT, f))
model = load_model(mv.MODEL)
sr = getattr(model, "sample_rate", 24000)
tmp = tempfile.mkdtemp()
for name, text in LINES.items():
out = []
for n, (shot, at, text) in enumerate(LINES):
# Room: to the next line's start (or the end of the film).
if n + 1 < len(LINES):
nshot, nat, _ = LINES[n + 1]
room = sum(SHOTS[shot:nshot]) - at + nat - GAP
else:
room = sum(SHOTS[shot:]) - at - 0.5
best = None
for k in range(3):
res = list(model.generate(text=text, ref_audio=mv.REF_AUDIO, ref_text=mv.REF_TEXT))
audio = np.concatenate([np.array(r.audio) for r in res])
wav = os.path.join(tmp, "%s_%d.wav" % (name, k))
wav = os.path.join(tmp, "%02d_%d.wav" % (n, k))
sf.write(wav, audio, sr)
heard = mlx_whisper.transcribe(wav, path_or_hf_repo=mv.WHISPER, language="en")["text"].strip()
score = mv.match(text, heard)
secs = len(audio) / sr
# Words first; then the one that fits.
score = mv.match(text, heard) - (0.05 if secs > room * 1.15 else 0.0)
if best is None or score > best[0]:
best = (score, wav, len(audio) / sr)
if score >= 0.98:
best = (score, wav, secs)
if score >= 0.98 and secs <= room:
break
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", best[1], "-ac", "2", "-c:a", "vorbis", "-strict", "-2",
"-q:a", "5", os.path.join(OUT, name + ".ogg")], check=True)
print(" %s (%.1f s, words %.0f%%)" % (name, best[2], best[0] * 100), flush=True)
json.dump(LINES, open(os.path.join(OUT, "lines.json"), "w"), indent=2)
score, wav, secs = best
tempo = min(max(secs / room, 1.0), 1.2) if room > 0 else 1.0
name = "%02d.ogg" % n
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", wav, "-af", "atempo=%.3f" % tempo, "-ac", "2",
"-c:a", "vorbis", "-strict", "-2", "-q:a", "5", os.path.join(OUT, name)], check=True)
out.append({"file": name, "shot": shot, "at": at, "text": text})
print(" %s shot %d: %.1f s%s (room %.1f s, words %.0f%%)" % (name, shot, secs / tempo,
" (x%.2f)" % tempo if tempo > 1.0 else "", room, score * 100), flush=True)
json.dump(out, open(os.path.join(OUT, "lines.json"), "w"), indent=2)
if __name__ == "__main__":

BIN
tools/trailer/voice/00.ogg Normal file

Binary file not shown.

BIN
tools/trailer/voice/01.ogg Normal file

Binary file not shown.

BIN
tools/trailer/voice/02.ogg Normal file

Binary file not shown.

BIN
tools/trailer/voice/03.ogg Normal file

Binary file not shown.

BIN
tools/trailer/voice/04.ogg Normal file

Binary file not shown.

BIN
tools/trailer/voice/05.ogg Normal file

Binary file not shown.

BIN
tools/trailer/voice/06.ogg Normal file

Binary file not shown.

BIN
tools/trailer/voice/07.ogg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,50 @@
{
"dawn": "Ha! Awake at last! Washed up on my island, eh?",
"wolves": "Hush now. See it, there, at the edge of the light?",
"cove": "Well, you made it down!"
}
[
{
"file": "00.ogg",
"shot": 0,
"at": 2.0,
"text": "Welcome to Cinderwild. You wake on a wild island, washed up with nothing but the clothes on your back."
},
{
"file": "01.ogg",
"shot": 1,
"at": 0.3,
"text": "Gather what you can. Hunt, forage, and cook over an open fire."
},
{
"file": "02.ogg",
"shot": 2,
"at": 0.3,
"text": "Build yourself a home, plank by plank, before the nights draw in."
},
{
"file": "03.ogg",
"shot": 3,
"at": 0.3,
"text": "Because when the sun goes down, the wolves come out. Keep your fire burning."
},
{
"file": "04.ogg",
"shot": 4,
"at": 0.3,
"text": "Weather the storms, and the hard winters."
},
{
"file": "05.ogg",
"shot": 5,
"at": 0.3,
"text": "Then dig down, through the sea cliffs."
},
{
"file": "06.ogg",
"shot": 6,
"at": 0.3,
"text": "And find out what lies beyond."
},
{
"file": "07.ogg",
"shot": 7,
"at": 0.6,
"text": "Play alone, or with your friends, on your own server. Cinderwild."
}
]

Binary file not shown.