#!/usr/bin/env python3 """ acoustic_book_hash.py ===================== An acoustic fingerprint for a physical book. A closed book is a layered resonator. Tap the cover and it rings with a set of modes whose frequencies depend on the paper stock, the number of pages, the binding, and the ambient humidity. Two copies of the same edition have nearly identical mode frequencies; different editions do not. The mode frequencies are a physical fingerprint of the book's construction. Applications: - Forgery detection in print-on-demand markets. - Edition matching for libraries and rare book dealers. - Copy-level tracking. - Physical provenance (humidity history). Limitations: - Requires the book to be closed and undamaged. - Requires a quiet recording environment. - Requires the same tap point, or frequency-based comparison. - Requires calibration against a reference edition. Pure numpy. Runs in a second. """ from __future__ import annotations import argparse import json import math import wave from dataclasses import dataclass, asdict from typing import Dict, List, Optional, Sequence, Tuple import numpy as np # ===================================================================== # §1 Physical model of a book # ===================================================================== @dataclass class BookParams: paper_speed: float stack_height: float damping_time: float n_modes: int = 20 edition: str = "unnamed" def fundamental_frequency(params: BookParams) -> float: return params.paper_speed / (2.0 * params.stack_height) def book_ir(params: BookParams, sample_rate: int, duration: float = 0.10, tap_seed: int = 0, noise_frac: float = 0.0, seed: int = 0) -> np.ndarray: c = params.paper_speed L = params.stack_height tau0 = params.damping_time N = params.n_modes f1 = c / (2.0 * L) t = np.arange(int(sample_rate * duration)) / sample_rate rng = np.random.default_rng(tap_seed) phases = rng.uniform(0.0, 2.0 * math.pi, N) h = np.zeros_like(t) for n in range(1, N + 1): f_n = n * f1 if f_n > 20000.0: break A_n = 1.0 / (n ** 1.5) tau_n = tau0 / (1.0 + 0.15 * (n - 1)) h += A_n * np.exp(-t / tau_n) * np.sin(2.0 * math.pi * f_n * t + phases[n - 1]) h *= (1.0 - np.exp(-t / 0.0005)) if noise_frac > 0.0: n_rng = np.random.default_rng(seed) rms = float(np.sqrt(np.mean(h ** 2))) h = h + noise_frac * rms * n_rng.standard_normal(len(t)) return h # ===================================================================== # §2 Spectral peak extraction # ===================================================================== def magnitude_spectrum(ir: np.ndarray, sample_rate: int, n_fft: int = 8192) -> Tuple[np.ndarray, np.ndarray]: S = np.fft.rfft(ir, n=n_fft) freqs = np.fft.rfftfreq(n_fft, d=1.0 / sample_rate) return np.abs(S), freqs def spectral_peaks(ir: np.ndarray, sample_rate: int, n_peaks: int = 8, fmin: float = 200.0, fmax: float = 6000.0, min_sep_hz: float = 80.0 ) -> List[Tuple[float, float]]: """Find up to `n_peaks` local maxima of the magnitude spectrum. Returns a list of (frequency_hz, amplitude) sorted by frequency. """ mag, freqs = magnitude_spectrum(ir, sample_rate) band = (freqs >= fmin) & (freqs <= fmax) m = mag[band] f = freqs[band] if len(m) < 3: return [] # Smooth k = max(1, int(round(min_sep_hz / (f[1] - f[0])))) kernel = np.ones(k) / k ms = np.convolve(m, kernel, mode="same") # Local maxima is_peak = np.zeros_like(ms, dtype=bool) is_peak[1:-1] = (ms[1:-1] >= ms[:-2]) & (ms[1:-1] >= ms[2:]) idx = np.where(is_peak)[0] if len(idx) == 0: return [] # Amplitudes at the peak positions amps = ms[idx] # Top n_peaks by amplitude (positions into `idx`) order = np.argsort(-amps)[:n_peaks] # Correct indexing: # idx[order] -> frequency-domain positions of selected peaks # amps[order] -> amplitudes of selected peaks peak_freqs = f[idx[order]] peak_amps = amps[order] # Sort by frequency sort_order = np.argsort(peak_freqs) peak_freqs = peak_freqs[sort_order] peak_amps = peak_amps[sort_order] # Merge peaks closer than min_sep_hz merged: List[Tuple[float, float]] = [] for pf, pa in zip(peak_freqs, peak_amps): pf = float(pf); pa = float(pa) if merged and (pf - merged[-1][0]) < min_sep_hz: if pa > merged[-1][1]: merged[-1] = (pf, pa) else: merged.append((pf, pa)) return merged # ===================================================================== # §3 Fingerprint and comparison # ===================================================================== @dataclass class Fingerprint: peaks: List[Tuple[float, float]] book_id: str = "" def frequencies(self) -> Tuple[float, ...]: return tuple(f for f, _ in self.peaks) def to_dict(self) -> dict: return { "book_id": self.book_id, "frequencies_hz": [round(f, 1) for f, _ in self.peaks], "amplitudes": [round(a, 6) for _, a in self.peaks], } def __str__(self) -> str: freqs = ", ".join(f"{f:.0f}" for f in self.frequencies()) return f"Fingerprint({self.book_id}, peaks=[{freqs}] Hz)" def make_fingerprint(ir: np.ndarray, sample_rate: int, book_id: str = "", n_peaks: int = 8, fmin: float = 200.0, fmax: float = 6000.0 ) -> Fingerprint: peaks = spectral_peaks(ir, sample_rate, n_peaks=n_peaks, fmin=fmin, fmax=fmax) return Fingerprint(peaks=peaks, book_id=book_id) def fingerprint_similarity(fp1: Fingerprint, fp2: Fingerprint, freq_tol: float = 150.0) -> float: f1 = fp1.frequencies() f2 = fp2.frequencies() if not f1 and not f2: return 1.0 if not f1 or not f2: return 0.0 used = [False] * len(f2) matches = 0 for f in f1: best = None best_dist = freq_tol for j, g in enumerate(f2): if used[j]: continue d = abs(f - g) if d < best_dist: best_dist = d best = j if best is not None: used[best] = True matches += 1 return 2.0 * matches / (len(f1) + len(f2)) # ===================================================================== # §4 Database # ===================================================================== @dataclass class Match: book_id: str similarity: float @dataclass class BookDatabase: entries: Dict[str, Fingerprint] = None def __post_init__(self): if self.entries is None: self.entries = {} def add(self, fp: Fingerprint) -> None: self.entries[fp.book_id] = fp def add_from_ir(self, ir: np.ndarray, sample_rate: int, book_id: str, **kwargs) -> Fingerprint: fp = make_fingerprint(ir, sample_rate, book_id=book_id, **kwargs) self.add(fp) return fp def query(self, query_fp: Fingerprint, freq_tol: float = 150.0, threshold: float = 0.6 ) -> Tuple[Optional[Match], List[Match]]: matches = [ Match(book_id=bid, similarity=fingerprint_similarity(query_fp, fp, freq_tol)) for bid, fp in self.entries.items() ] matches.sort(key=lambda m: -m.similarity) if not matches: return None, [] if matches[0].similarity < threshold: return None, matches return matches[0], matches def save(self, path: str) -> None: data = {bid: fp.to_dict() for bid, fp in self.entries.items()} with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) @classmethod def load(cls, path: str) -> "BookDatabase": with open(path, "r", encoding="utf-8") as f: data = json.load(f) db = cls() for bid, entry in data.items(): peaks = list(zip(entry["frequencies_hz"], entry["amplitudes"])) db.add(Fingerprint(peaks=peaks, book_id=bid)) return db # ===================================================================== # §5 WAV I/O and windowing # ===================================================================== def load_wav(path: str) -> Tuple[np.ndarray, int]: with wave.open(path, "rb") as w: n_ch = w.getnchannels() samp_w = w.getsampwidth() sr = w.getframerate() raw = w.readframes(w.getnframes()) if samp_w == 2: data = (np.frombuffer(raw, dtype=np.int16) .astype(np.float64) / 32768.0) elif samp_w == 4: data = (np.frombuffer(raw, dtype=np.int32) .astype(np.float64) / 2147483648.0) elif samp_w == 1: data = ((np.frombuffer(raw, dtype=np.uint8) .astype(np.float64) - 128.0) / 128.0) else: raise ValueError(f"unsupported sample width: {samp_w}") if n_ch > 1: data = data.reshape(-1, n_ch).mean(axis=1) return data, sr def window_tap(ir: np.ndarray, sample_rate: int, window_ms: float = 30.0, trim_threshold: float = 0.02) -> np.ndarray: peak = float(np.max(np.abs(ir))) if peak < 1e-9: return ir above = np.abs(ir) > (trim_threshold * peak) if not above.any(): return ir start = int(np.argmax(above)) n = int(sample_rate * window_ms / 1000.0) end = min(len(ir), start + n) return ir[start:end] # ===================================================================== # §6 Demo data # ===================================================================== def make_demo_editions() -> List[BookParams]: return [ BookParams(paper_speed=80.0, stack_height=0.050, damping_time=0.050, edition="EdA-hardcover-2003"), BookParams(paper_speed=85.0, stack_height=0.048, damping_time=0.045, edition="EdB-paperback-2010"), BookParams(paper_speed=75.0, stack_height=0.055, damping_time=0.055, edition="EdC-folio-1897"), BookParams(paper_speed=90.0, stack_height=0.045, damping_time=0.042, edition="EdD-textbook-2018"), BookParams(paper_speed=78.0, stack_height=0.052, damping_time=0.052, edition="EdE-pulp-1972"), ] # ===================================================================== # §7 Self-test # ===================================================================== def self_test(verbose: bool = True) -> Tuple[int, int]: checks = [] sr = 48000 editions = make_demo_editions() ir = book_ir(editions[0], sr, tap_seed=0) checks.append(("book_ir finite", bool(np.all(np.isfinite(ir))))) checks.append(("book_ir length matches duration", len(ir) == int(sr * 0.10))) f1 = fundamental_frequency(editions[0]) checks.append((f"fundamental frequency sensible " f"({f1:.1f} Hz)", 500.0 < f1 < 2000.0)) peaks = spectral_peaks(ir, sr, n_peaks=8) checks.append(("peaks found", len(peaks) >= 3)) checks.append(("peaks in band", all(200 <= f <= 6000 for f, _ in peaks))) ir_a = book_ir(editions[0], sr, tap_seed=1) ir_b = book_ir(editions[0], sr, tap_seed=2) fp_a = make_fingerprint(ir_a, sr, book_id="A") fp_b = make_fingerprint(ir_b, sr, book_id="B") sim = fingerprint_similarity(fp_a, fp_b) checks.append((f"same edition, different taps: sim {sim:.3f}", sim > 0.85)) ir_c = book_ir(editions[1], sr, tap_seed=1) fp_c = make_fingerprint(ir_c, sr, book_id="C") sim_diff = fingerprint_similarity(fp_a, fp_c) checks.append((f"different editions: sim {sim_diff:.3f}", sim_diff < 0.4)) ir_n = book_ir(editions[0], sr, tap_seed=1, noise_frac=0.03, seed=42) fp_n = make_fingerprint(ir_n, sr, book_id="N") sim_noise = fingerprint_similarity(fp_a, fp_n) checks.append((f"same book + 3% noise: sim {sim_noise:.3f}", sim_noise > 0.7)) db = BookDatabase() for ed in editions: ir_ed = book_ir(ed, sr, tap_seed=1) db.add_from_ir(ir_ed, sr, ed.edition) query_ir = book_ir(editions[2], sr, tap_seed=7, noise_frac=0.02, seed=99) query_fp = make_fingerprint(query_ir, sr, book_id="query") best, _ = db.query(query_fp, threshold=0.5) checks.append((f"identify query: {best.book_id if best else None}", best is not None and best.book_id == editions[2].edition)) unknown_params = BookParams( paper_speed=200.0, stack_height=0.02, damping_time=0.03, edition="unknown") unknown_ir = book_ir(unknown_params, sr, tap_seed=1) unknown_fp = make_fingerprint(unknown_ir, sr, book_id="?") best_u, all_u = db.query(unknown_fp, threshold=0.7) checks.append((f"unknown rejected " f"(best sim {all_u[0].similarity:.3f})", best_u is None)) ir_x1 = book_ir(editions[0], sr, tap_seed=5, noise_frac=0.01, seed=11) ir_x2 = book_ir(editions[0], sr, tap_seed=5, noise_frac=0.01, seed=11) checks.append(("deterministic simulation", bool(np.allclose(ir_x1, ir_x2)))) fp_dict = fp_a.to_dict() checks.append(("fingerprint serialises", "frequencies_hz" in fp_dict and len(fp_dict["frequencies_hz"]) == len(fp_a.peaks))) passed = sum(1 for _, ok in checks if ok) if verbose: print() print("=" * 74) print("SELF-TEST") print("=" * 74) for name, ok in checks: mark = "PASS" if ok else "FAIL" print(f" [{mark}] {name}") print() print(f" {passed}/{len(checks)} correct") return passed, len(checks) # ===================================================================== # §8 Demo # ===================================================================== def banner(t: str, w: int = 76) -> None: print() print("=" * w) print(t) print("=" * w) def demo() -> None: sr = 48000 editions = make_demo_editions() banner("ACOUSTIC BOOK HASH -- demo") print(" Editions in the demo shelf:") print() print(f" {'edition':<26} {'f1 (Hz)':>9} " f"{'stack (mm)':>11} {'tau0 (ms)':>10}") print(" " + "-" * 60) for ed in editions: f1 = fundamental_frequency(ed) print(f" {ed.edition:<26} {f1:>9.1f} " f"{ed.stack_height*1000:>11.1f} " f"{ed.damping_time*1000:>10.1f}") banner("FINGERPRINTS -- one tap per edition") fps = [] for ed in editions: ir = book_ir(ed, sr, tap_seed=1) fp = make_fingerprint(ir, sr, book_id=ed.edition) fps.append(fp) print(f" {ed.edition}") print(f" peaks: {[round(f,0) for f in fp.frequencies()]} Hz") banner("PAIRWISE SIMILARITY MATRIX") print() header = " " + " " * 26 + " ".join( f"{ed.edition[:7]:>7}" for ed in editions) print(header) print(" " + "-" * (len(header) - 2)) for i, ed_i in enumerate(editions): row = f" {ed_i.edition:<26}" for j, _ in enumerate(editions): s = fingerprint_similarity(fps[i], fps[j]) row += f" {s:>7.3f}" print(row) banner("ROBUSTNESS -- same book, varying conditions") base_ed = editions[0] base_ir = book_ir(base_ed, sr, tap_seed=1) base_fp = make_fingerprint(base_ir, sr, book_id="base") print() print(f" {'condition':<34} {'similarity':>10}") print(" " + "-" * 48) for label, kwargs in [ ("same tap point (re-tap)", dict(tap_seed=1)), ("different tap point", dict(tap_seed=5)), ("different tap point", dict(tap_seed=12)), ("+ 1% noise", dict(tap_seed=1, noise_frac=0.01, seed=1)), ("+ 3% noise", dict(tap_seed=1, noise_frac=0.03, seed=2)), ("+ 5% noise", dict(tap_seed=1, noise_frac=0.05, seed=3)), ("+ 10% noise", dict(tap_seed=1, noise_frac=0.10, seed=4)), ]: ir = book_ir(base_ed, sr, **kwargs) fp = make_fingerprint(ir, sr, book_id="test") s = fingerprint_similarity(base_fp, fp) print(f" {label:<34} {s:>10.3f}") banner("DATABASE IDENTIFICATION") db = BookDatabase() for ed in editions: ir_ed = book_ir(ed, sr, tap_seed=1) db.add_from_ir(ir_ed, sr, ed.edition) print(f" database contains {len(db.entries)} editions") print() print(f" {'query':<26} {'best match':<26} " f"{'score':>6} {'verdict':>10}") print(" " + "-" * 76) queries = [ ("fresh tap of EdA", editions[0], dict(tap_seed=99)), ("fresh tap of EdC", editions[2], dict(tap_seed=99)), ("fresh tap of EdE", editions[4], dict(tap_seed=99)), ("EdA + 5% noise", editions[0], dict(tap_seed=3, noise_frac=0.05, seed=77)), ("unknown book (EdX)", BookParams( paper_speed=200.0, stack_height=0.020, damping_time=0.030, edition="EdX-unknown"), dict(tap_seed=1)), ] for label, params, kwargs in queries: ir_q = book_ir(params, sr, **kwargs) fp_q = make_fingerprint(ir_q, sr, book_id="query") best, _ = db.query(fp_q, threshold=0.6) match_label = best.book_id if best else "(no match)" score = best.similarity if best else 0.0 verdict = "MATCH" if best else "UNKNOWN" print(f" {label:<26} {match_label:<26} " f"{score:>6.3f} {verdict:>10}") banner("WHAT THIS ESTABLISHES") print(""" The fingerprints above are computed from the physical model, not from real recordings. Two copies of the same edition produce fingerprints with high similarity (above 0.85); two different editions produce fingerprints with low similarity (below 0.4). The tool separates them without training, without labels, and without knowing which is which. The robustness sweep shows that similarity degrades gracefully with noise. At 5% noise the same-edition similarity is still above 0.7, and the threshold at 0.6 correctly classifies it as a match. At 10% noise the similarity drops below 0.6 and the classifier would report "unknown" -- the honest answer when the signal is that degraded. To use this on real books: replace book_ir() with a function that reads a WAV file, or call the CLI with record-db and query. """) # ===================================================================== # §9 CLI # ===================================================================== def _cmd_record_db(args) -> None: import sys argv = sys.argv[2:] db_path = argv[0] argv = argv[1:] db = BookDatabase() i = 0 while i < len(argv): if argv[i] == "--book": name = argv[i + 1] files = [] i += 2 while i < len(argv) and not argv[i].startswith("--"): files.append(argv[i]) i += 1 peak_lists = [] for path in files: data, sr = load_wav(path) data = window_tap(data, sr) peaks = spectral_peaks(data, sr) peak_lists.append(peaks) merged: Dict[int, Tuple[float, float]] = {} for pl in peak_lists: for f, a in pl: key = int(round(f / 100.0)) if key not in merged or a > merged[key][1]: merged[key] = (f, a) all_peaks = sorted(merged.values(), key=lambda p: p[0]) fp = Fingerprint(peaks=all_peaks, book_id=name) db.add(fp) print(f" added {name}: {len(all_peaks)} peaks") else: i += 1 db.save(db_path) print(f" saved database to {db_path}") def _cmd_query(args) -> None: db = BookDatabase.load(args.db) data, sr = load_wav(args.wav) data = window_tap(data, sr) fp = make_fingerprint(data, sr, book_id="query") best, all_matches = db.query(fp, threshold=args.threshold) if args.json: print(json.dumps({ "query_peaks_hz": [round(f, 1) for f in fp.frequencies()], "best_match": (best.book_id if best else None), "best_similarity": (best.similarity if best else 0.0), "all_matches": [asdict(m) for m in all_matches], }, indent=2)) else: print(f"query peaks: " f"{[round(f,0) for f in fp.frequencies()]} Hz") print() for m in all_matches: print(f" {m.book_id:<30} {m.similarity:.3f}") print() if best: print(f"best match: {best.book_id} " f"({best.similarity:.3f})") else: print("no match above threshold") def main(): p = argparse.ArgumentParser( description="Acoustic fingerprint for physical books.") sub = p.add_subparsers(dest="cmd") sub.add_parser("self-test") sub.add_parser("demo") p_rec = sub.add_parser("record-db") p_rec.add_argument("db_path", type=str) p_q = sub.add_parser("query") p_q.add_argument("db", type=str) p_q.add_argument("wav", type=str) p_q.add_argument("--threshold", type=float, default=0.6) p_q.add_argument("--json", action="store_true") args = p.parse_args() if args.cmd in (None, "self-test"): self_test() if args.cmd is None: print() print(" usage:") print(" python acoustic_book_hash.py self-test") print(" python acoustic_book_hash.py demo") print(" python acoustic_book_hash.py record-db " "mylib.json --book EdA a1.wav a2.wav " "--book EdB b1.wav") print(" python acoustic_book_hash.py query " "mylib.json tap.wav") return if args.cmd == "demo": demo() return if args.cmd == "record-db": _cmd_record_db(args) return if args.cmd == "query": _cmd_query(args) return if __name__ == "__main__": main()