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