P13.1: fix F2 — retire stale procedural synth, doc the real Triple Treat DSP path
The README audio-regeneration section called tools/synth_audio.py the 'build-time DSP path', but that script is the original P10.1 procedural synthesizer: its main() overwrites assets/audio/*.wav with note-frequency synthetic audio. Following the documented path would clobber the curated Triple Treat pack cues. Remove the script (cleanest resolution — kills the clobber hazard) and rewrite the README to describe the actual production: real pack clips (per-cue source in assets/audio/LICENSE.txt) down-mixed, trimmed, faded, peak-normalized to ~-15 dBFS, re-wrapped via afconvert; combo ladder = real Match FX ordered by brightness (P10.9); cascade one cue per round (P10.10). measure_pitch.py kept (read-only verification, never writes a WAV). WAVs/.sx/goldens byte-unchanged.
This commit is contained in:
25
README.md
25
README.md
@@ -300,13 +300,24 @@ never run at play time. Refresh any affected `goldens/` after an art change.
|
||||
|
||||
### Audio (the SFX bank)
|
||||
|
||||
The shipped cues are the Triple Treat selections above, converted to the canonical
|
||||
container with `afconvert` (WAVE / `LEI16` / 44100 / mono) and verified with
|
||||
`tools/measure_pitch.py` — the combo cues' spectral centroids must ascend
|
||||
monotonically (and beat `clear.wav`'s ~784 Hz CC0 baseline reference clip).
|
||||
`tools/synth_audio.py` is the build-time DSP path (down-mix, trim, fade, peak-
|
||||
normalize, optional resample). The 30 MB pack and its `.meta` / `__MACOSX` cruft
|
||||
are not committed.
|
||||
The shipped cues are **not synthesized** — each is a real **Triple Treat SFX** pack
|
||||
clip selected per game event (per-cue source in `assets/audio/LICENSE.txt`). To
|
||||
re-derive a cue from the pack, take its source file and apply the same DSP the
|
||||
shipped bank used: down-mix to mono, trim to the punchy onset window (≤ ~600 ms),
|
||||
ease in with a short fade, round out with a cosine fade-out tail, peak-normalize to
|
||||
a gentle **−15 dBFS**, then re-wrap to the canonical container with `afconvert`
|
||||
(`afconvert -f WAVE -d LEI16@44100 -c 1 in.wav out.wav` → WAVE / `LEI16` / 44100 /
|
||||
mono). The combo ladder is five real **Match FX** cues ordered by ascending spectral
|
||||
brightness (P10.9, `combo1`→`combo5`); the cascade plays one cue per round on the
|
||||
animation timeline (P10.10). Verify the result read-only with `tools/measure_pitch.py`
|
||||
(it never writes a WAV) — it prints each clip's dominant frequency, and `clear.wav`'s
|
||||
~784 Hz CC0 reference is the documented baseline. The 30 MB pack and its `.meta` /
|
||||
`__MACOSX` cruft are not committed.
|
||||
|
||||
> There is intentionally **no procedural-synthesis regeneration script**: the cues
|
||||
> are the curated pack clips, so the original P10.1 note-frequency synthesizer
|
||||
> (`tools/synth_audio.py`) was removed — running it would have clobbered the curated
|
||||
> WAVs with synthetic audio.
|
||||
|
||||
### Image art
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synthesize the m3te candy-vibe SFX bank (P10.1).
|
||||
|
||||
BUILD-TIME TOOL ONLY. The shipped game never runs this; it only loads the
|
||||
finished WAVs this script writes under assets/audio/. Pure Python stdlib
|
||||
synthesis (no numpy) -> standard 16-bit PCM WAV -> afconvert into the exact
|
||||
canonical iOS System-Sound format clear.wav already uses (mono, 44100 Hz,
|
||||
LEI16, WAVE_FORMAT_EXTENSIBLE).
|
||||
|
||||
Timbre: a bright glossy bell — fundamental plus a few decaying partials and a
|
||||
slightly detuned shimmer top, a fast attack, exponential decay, and a tiny
|
||||
upward pitch blip at onset for the "candy pop" feel. Every cue sits well above
|
||||
clear.wav's ~784 Hz fundamental. combo1..combo5 climb a pentatonic run.
|
||||
|
||||
Usage: python3 tools/synth_audio.py # writes the whole bank
|
||||
python3 tools/synth_audio.py --keep-tmp # leave the intermediate PCM
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import wave
|
||||
|
||||
SR = 44100
|
||||
|
||||
# Equal-tempered note frequencies (A4 = 440 Hz).
|
||||
C6, D6, E6, G6, A6, C7 = 1046.50, 1174.66, 1318.51, 1567.98, 1760.00, 2093.00
|
||||
|
||||
OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "assets", "audio")
|
||||
|
||||
# A glossy candy bell: fundamental + gently decaying harmonics + a slightly
|
||||
# detuned high partial for shimmer. Kept clean (no harsh odd-only stack).
|
||||
GLOSSY = [(1.0, 1.00), (2.0, 0.34), (3.0, 0.13), (4.02, 0.06), (6.01, 0.03)]
|
||||
# Softer/darker partial set for the gentle "lose" stinger.
|
||||
GENTLE = [(1.0, 1.00), (2.0, 0.22), (3.0, 0.07)]
|
||||
|
||||
|
||||
def note(freq, dur, partials=GLOSSY, decay=0.18, blip=0.06, blip_tau=0.012,
|
||||
attack=0.004):
|
||||
"""One glossy bell note as a list of floats (mono, length dur*SR).
|
||||
|
||||
decay: exponential amplitude time-constant (s).
|
||||
blip: fractional upward pitch offset at onset, settling with blip_tau.
|
||||
"""
|
||||
n = int(dur * SR)
|
||||
out = [0.0] * n
|
||||
phases = [0.0] * len(partials)
|
||||
for i in range(n):
|
||||
t = i / SR
|
||||
# Onset pitch blip: starts ~blip above target, settles fast.
|
||||
fmul = 1.0 + blip * math.exp(-t / blip_tau)
|
||||
amp = math.exp(-t / decay)
|
||||
if t < attack: # linear attack from 0 (no click)
|
||||
amp *= t / attack
|
||||
s = 0.0
|
||||
for k, (ratio, level) in enumerate(partials):
|
||||
phases[k] += 2.0 * math.pi * freq * ratio * fmul / SR
|
||||
s += level * math.sin(phases[k])
|
||||
out[i] = amp * s
|
||||
return out
|
||||
|
||||
|
||||
def mix(layers):
|
||||
"""Overlay (freq, start_s, samples) layers onto one buffer."""
|
||||
end = max(int(start * SR) + len(buf) for _, start, buf in layers)
|
||||
out = [0.0] * end
|
||||
for _, start, buf in layers:
|
||||
off = int(start * SR)
|
||||
for i, v in enumerate(buf):
|
||||
out[off + i] += v
|
||||
return out
|
||||
|
||||
|
||||
def finalize(samples, peak=0.89, fade=0.008):
|
||||
"""Soft-clip for warmth, normalize, and fade the tail to avoid a click."""
|
||||
samples = [math.tanh(1.3 * s) for s in samples]
|
||||
m = max(1e-9, max(abs(s) for s in samples))
|
||||
g = peak / m
|
||||
samples = [s * g for s in samples]
|
||||
fn = int(fade * SR)
|
||||
n = len(samples)
|
||||
for i in range(min(fn, n)): # linear tail fade
|
||||
samples[n - 1 - i] *= i / fn
|
||||
return samples
|
||||
|
||||
|
||||
def write_pcm16(path, samples):
|
||||
with wave.open(path, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(SR)
|
||||
frames = bytearray()
|
||||
for s in samples:
|
||||
v = int(max(-1.0, min(1.0, s)) * 32767.0)
|
||||
frames += struct.pack("<h", v)
|
||||
w.writeframes(bytes(frames))
|
||||
|
||||
|
||||
def to_canonical(tmp_pcm, out_wav):
|
||||
# Match clear.wav exactly: WAVE / LEI16 / 44100 / mono via afconvert.
|
||||
subprocess.run(
|
||||
["afconvert", "-f", "WAVE", "-d", "LEI16@44100", "-c", "1",
|
||||
tmp_pcm, out_wav],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
# --- the bank -------------------------------------------------------------
|
||||
|
||||
def glide_note(f0, f1, dur, partials=GLOSSY, decay=0.12, attack=0.004):
|
||||
"""A note whose pitch glides f0 -> f1 across its length (whoosh)."""
|
||||
n = int(dur * SR)
|
||||
out = [0.0] * n
|
||||
phases = [0.0] * len(partials)
|
||||
for i in range(n):
|
||||
t = i / SR
|
||||
frac = t / dur
|
||||
freq = f0 * (1.0 - frac) + f1 * frac
|
||||
amp = math.exp(-t / decay)
|
||||
if t < attack:
|
||||
amp *= t / attack
|
||||
s = 0.0
|
||||
for k, (ratio, level) in enumerate(partials):
|
||||
phases[k] += 2.0 * math.pi * freq * ratio / SR
|
||||
s += level * math.sin(phases[k])
|
||||
out[i] = amp * s
|
||||
return out
|
||||
|
||||
|
||||
def build_swap():
|
||||
# Soft glossy tick/whoosh: short, gentle attack, a downward pitch glide.
|
||||
return finalize(glide_note(D6, C6 * 0.94, 0.13, decay=0.06, attack=0.006,
|
||||
partials=[(1.0, 1.0), (2.0, 0.20), (3.0, 0.07)]),
|
||||
peak=0.80)
|
||||
|
||||
|
||||
def build_match():
|
||||
# Bright candy pop: punchy E6 with a strong onset blip.
|
||||
return finalize(note(E6, 0.17, decay=0.10, blip=0.10, blip_tau=0.010))
|
||||
|
||||
|
||||
def build_combo(freq):
|
||||
# One step of the ascending chime run: glossy, a touch of ring.
|
||||
return finalize(note(freq, 0.22, decay=0.16, blip=0.05, blip_tau=0.010))
|
||||
|
||||
|
||||
def build_win():
|
||||
# Triumphant ascending arpeggio C6-E6-G6-C7, notes ring together.
|
||||
notes = [(C6, 0.00), (E6, 0.07), (G6, 0.14), (C7, 0.21)]
|
||||
layers = [(f, t, note(f, 0.30, decay=0.20, blip=0.04)) for f, t in notes]
|
||||
return finalize(mix(layers), peak=0.92)
|
||||
|
||||
|
||||
def build_lose():
|
||||
# Gentle descending stinger G6-E6-C6, soft and dark.
|
||||
notes = [(G6, 0.00), (E6, 0.11), (C6, 0.22)]
|
||||
layers = [(f, t, note(f, 0.28, partials=GENTLE, decay=0.18, blip=0.0))
|
||||
for f, t in notes]
|
||||
return finalize(mix(layers), peak=0.72)
|
||||
|
||||
|
||||
BANK = {
|
||||
"swap": build_swap,
|
||||
"match": build_match,
|
||||
"combo1": lambda: build_combo(C6),
|
||||
"combo2": lambda: build_combo(D6),
|
||||
"combo3": lambda: build_combo(E6),
|
||||
"combo4": lambda: build_combo(G6),
|
||||
"combo5": lambda: build_combo(A6),
|
||||
"win": build_win,
|
||||
"lose": build_lose,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
keep = "--keep-tmp" in sys.argv[1:]
|
||||
out_dir = os.path.normpath(OUT_DIR)
|
||||
for name, build in BANK.items():
|
||||
samples = build()
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
|
||||
tmp = tf.name
|
||||
write_pcm16(tmp, samples)
|
||||
out = os.path.join(out_dir, name + ".wav")
|
||||
to_canonical(tmp, out)
|
||||
if not keep:
|
||||
os.remove(tmp)
|
||||
print("wrote %s (%.3f s)" % (out, len(samples) / SR))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user