P10.1: candy-vibe higher-pitched SFX bank (sx / iOS assets)

Produce the first deliverable of the Candy-Crush vibe pass: a bank of bright,
glossy, higher-pitched SFX WAVs under assets/audio/, all in the exact canonical
iOS System-Sound format clear.wav uses (mono, 44100 Hz, signed-16-bit PCM).

Bank: swap, match, combo1..combo5 (ascending pentatonic run C6 D6 E6 G6 A6),
win (ascending arpeggio), lose (descending stinger). Every cue sits above
clear.wav's ~784 Hz fundamental; combo1<..<combo5 step strictly upward
(1047<1175<1319<1568<1760 Hz). Each loads via AudioServicesCreateSystemSoundID
with status 0.

Synthesized by the build-time tools/synth_audio.py (pure-Python additive
synthesis; the app never runs it) and converted with afconvert. Pitch verified
with tools/measure_pitch.py. Provenance (CC0) recorded in LICENSE.txt. No sx
code changes — engine wiring is P10.2/P10.3.
This commit is contained in:
swipelab
2026-06-05 19:28:11 +03:00
parent 0d9ee13984
commit 7f23bc8b19
12 changed files with 313 additions and 0 deletions

View File

@@ -7,3 +7,25 @@ https://creativecommons.org/publicdomain/zero/1.0/
Converted to mono 44.1 kHz signed-16-bit PCM WAV (afconvert) — the format
iOS System Sound Services loads directly via audio.sx.
Candy-vibe SFX bank (P10.1)
---------------------------
swap.wav, match.wav, combo1.wav .. combo5.wav, win.wav, lose.wav
Original works, synthesized from scratch for m3te by the project. No sampled
or third-party audio. Released into the public domain under CC0 1.0:
https://creativecommons.org/publicdomain/zero/1.0/
Produced by the build-time tool tools/synth_audio.py (pure-Python stdlib
additive synthesis: a glossy bell timbre — fundamental plus a few decaying
partials and a detuned shimmer top, fast attack, exponential decay, onset
pitch blip), then converted to the canonical iOS System-Sound format
(WAVE / mono / 44100 Hz / signed-16-bit PCM) with afconvert. The shipped
game never runs this tool; it only loads the finished WAVs.
All cues sit above clear.wav's ~784 Hz fundamental. Measured fundamentals
(tools/measure_pitch.py): swap 1023, match 1319, combo1 1047, combo2 1175,
combo3 1319, combo4 1568, combo5 1760 Hz (combo1<..<combo5, ascending);
win is an ascending arpeggio C6-E6-G6-C7, lose a descending G6-E6-C6.

BIN
assets/audio/combo1.wav Normal file

Binary file not shown.

BIN
assets/audio/combo2.wav Normal file

Binary file not shown.

BIN
assets/audio/combo3.wav Normal file

Binary file not shown.

BIN
assets/audio/combo4.wav Normal file

Binary file not shown.

BIN
assets/audio/combo5.wav Normal file

Binary file not shown.

BIN
assets/audio/lose.wav Normal file

Binary file not shown.

BIN
assets/audio/match.wav Normal file

Binary file not shown.

BIN
assets/audio/swap.wav Normal file

Binary file not shown.

BIN
assets/audio/win.wav Normal file

Binary file not shown.

98
tools/measure_pitch.py Normal file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Estimate the dominant/fundamental frequency of a mono 16-bit PCM WAV.
Build-time verification tool only (the app never runs it). Pure stdlib: a
radix-2 Cooley-Tukey FFT over the loudest windowed segment, then a parabolic
peak interpolation. Prints "<file> fundamental=<Hz> peak_bin=<Hz> dur=<s>".
Usage: python3 tools/measure_pitch.py <file.wav> [<file.wav> ...]
"""
import cmath
import math
import struct
import sys
def read_mono16(path):
# Manual RIFF walk: afconvert emits WAVE_FORMAT_EXTENSIBLE (tag 0xFFFE) and
# a padded data offset, which Python's `wave` module rejects. We only need
# the fmt geometry and the raw 16-bit samples, so parse the chunks directly.
with open(path, "rb") as f:
data = f.read()
assert data[0:4] == b"RIFF" and data[8:12] == b"WAVE", "not a RIFF/WAVE file"
ch = rate = bits = None
pcm = None
pos = 12
while pos + 8 <= len(data):
cid = data[pos:pos + 4]
size = struct.unpack("<I", data[pos + 4:pos + 8])[0]
body = data[pos + 8:pos + 8 + size]
if cid == b"fmt ":
ch, rate = struct.unpack("<HI", body[2:8])
bits = struct.unpack("<H", body[14:16])[0]
elif cid == b"data":
pcm = body
pos += 8 + size + (size & 1) # chunks are word-aligned
assert bits == 16, "expected 16-bit PCM, got %r" % bits
samples = struct.unpack("<%dh" % (len(pcm) // 2), pcm)
if ch > 1: # average down to mono
samples = [sum(samples[i:i + ch]) / ch for i in range(0, len(samples), ch)]
return list(samples), rate
def fft(a):
n = len(a)
if n == 1:
return a
even = fft(a[0::2])
odd = fft(a[1::2])
out = [0] * n
for k in range(n // 2):
t = cmath.exp(-2j * math.pi * k / n) * odd[k]
out[k] = even[k] + t
out[k + n // 2] = even[k] - t
return out
def dominant_freq(samples, rate, fft_size=16384):
# Pick the loudest fft_size-long window (the tonal body, not silence).
if len(samples) < fft_size:
seg = list(samples) + [0.0] * (fft_size - len(samples))
else:
step = fft_size // 2
best_e, best_i = -1.0, 0
for i in range(0, len(samples) - fft_size + 1, step):
e = sum(s * s for s in samples[i:i + fft_size])
if e > best_e:
best_e, best_i = e, i
seg = list(samples[best_i:best_i + fft_size])
# Hann window to tame leakage, then FFT.
win = [seg[i] * (0.5 - 0.5 * math.cos(2 * math.pi * i / (fft_size - 1)))
for i in range(fft_size)]
spec = fft(win)
half = fft_size // 2
mag = [abs(spec[k]) for k in range(half)]
# Ignore DC / sub-audible bins below ~50 Hz.
lo = max(1, int(50 * fft_size / rate))
peak = max(range(lo, half), key=lambda k: mag[k])
# Parabolic interpolation around the peak for sub-bin accuracy.
if 0 < peak < half - 1:
a, b, c = mag[peak - 1], mag[peak], mag[peak + 1]
denom = (a - 2 * b + c)
delta = 0.5 * (a - c) / denom if denom != 0 else 0.0
else:
delta = 0.0
return (peak + delta) * rate / fft_size, peak * rate / fft_size
def main():
for path in sys.argv[1:]:
samples, rate = read_mono16(path)
f, peak_bin = dominant_freq(samples, rate)
dur = len(samples) / rate
print("%-28s fundamental=%7.1f Hz peak_bin=%7.1f Hz dur=%.3f s" %
(path, f, peak_bin, dur))
if __name__ == "__main__":
main()

193
tools/synth_audio.py Normal file
View File

@@ -0,0 +1,193 @@
#!/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()