#!/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 " fundamental= peak_bin= dur=". Usage: python3 tools/measure_pitch.py [ ...] """ 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(" 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()