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.
99 lines
3.4 KiB
Python
99 lines
3.4 KiB
Python
#!/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()
|