Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/hitenpatel/songprint/llms.txt

Use this file to discover all available pages before exploring further.

songprint’s fingerprinting pipeline converts raw PCM audio into a sparse set of spectral peaks that remain robust to noise, EQ changes, and lossy codecs. This is why the same algorithm identifies songs on live radio streams. The five-step pipeline — spectrogram, peak spreading, peak recognition, frequency banding, and binary encoding — runs in pure Kotlin with no native code or JVM-specific dependencies.
1

Step 1: Spectrogram

The pipeline starts by building a magnitude spectrogram from the raw audio.Input: 16kHz mono 16-bit PCM (ShortArray). Use Pcm.resampleToMono16k to convert from other formats before calling SignatureGenerator.Audio is fed into a circular buffer (sampleRing) of FFT_SIZE samples. Every HOP_SIZE samples, a new FFT frame is computed:
  1. The 2048-sample window is multiplied element-wise by a Hanning window to reduce spectral leakage.
  2. A 2048-point in-place DFT (pure-Kotlin Cooley-Tukey) transforms the windowed samples into the frequency domain.
  3. Per-bin magnitude is computed as (re² + im²) / 2¹⁷, floored at 1e-10 to avoid ln(0) later in the pipeline.
  4. The resulting 1025-bin magnitude array (FFT_OUTPUT_SIZE = FFT_SIZE / 2 + 1) is written into a ring buffer of 256 frames (fftOutputs).
for (i in 0 until FFT_OUTPUT_SIZE) {
    magnitudes[i] = max(
        (real[i] * real[i] + imag[i] * imag[i]) / (1 shl 17).toDouble(),
        1e-10
    )
}
With a hop size of 128 samples at 16kHz, each frame represents approximately 8ms of audio.
2

Step 2: Peak spreading

Raw magnitudes alone are noisy peak detectors. Before recognition, the spectrogram is smeared into a smooth “local maximum” surface.For each new FFT frame:
  1. Frequency spreading — starting from the freshly computed magnitude array, each bin i is replaced with max(spread[i], spread[i+1], spread[i+2]). This forward-spreads energy across the next two bins.
  2. Time spreading — the spread values are propagated backwards into three earlier frames in the ring buffer at offsets -1, -3, and -6 relative to the current write position. Each earlier frame’s bin value is raised to the running maximum, so a strong peak in the current frame influences the surface of past frames.
for (pos in 0 until FFT_OUTPUT_SIZE) {
    var maxVal = spread[pos]
    for (off in intArrayOf(-1, -3, -6)) {
        val idx = (spreadOutPos + off + RING_SIZE) % RING_SIZE
        spreadOutputs[idx][pos] = max(spreadOutputs[idx][pos], maxVal)
        maxVal = max(spreadOutputs[idx][pos], maxVal)
    }
}
The result (spreadOutputs) is a rolling surface where every bin reflects the recent local maximum across its frequency neighbourhood and across a short time window. Peak recognition then checks candidates against this surface.
3

Step 3: Peak recognition

Peak recognition runs once at least 46 spread frames have accumulated. It evaluates every bin in the frame from 46 frames ago (fftMinus46) against the spread surface built from surrounding frames.A bin at position b in frame f-46 is accepted as a peak only if all of the following conditions hold:
1

Minimum magnitude

fftMinus46[b] >= 1.0 / 64.0 — bins below the noise floor are ignored.
2

Frequency dominance (left neighbour)

fftMinus46[b] > spreadMinus49[b - 1] — the candidate must exceed the spread surface at the adjacent lower bin, 3 frames earlier.
3

Frequency neighbourhood

The candidate must exceed the spread surface at offsets -10, -7, -4, -3, +1, +2, +5, +8 bins (evaluated against spreadMinus49). Any neighbour that is stronger eliminates the candidate.
4

Time neighbourhood

The candidate must also exceed the spread surface at:
  • Frames f-53 and f-45 at bin b-1
  • Future frames at ring-buffer offsets 165–200 (step 7) and 214–250 (step 7) at bin b-1
Candidates that pass all four gates then have their frequency bin refined to 1/64-bin precision using parabolic interpolation of the log-magnitudes of the three adjacent bins:
val peakMag      = ln(max(1.0 / 64.0, fftMinus46[binPos]))     * 1477.3 + 6144.0
val peakMagBefore = ln(max(1.0 / 64.0, fftMinus46[binPos - 1])) * 1477.3 + 6144.0
val peakMagAfter  = ln(max(1.0 / 64.0, fftMinus46[binPos + 1])) * 1477.3 + 6144.0

val variation1 = peakMag * 2.0 - peakMagBefore - peakMagAfter
val variation2 = if (variation1 != 0.0) (peakMagAfter - peakMagBefore) * 32.0 / variation1 else 0.0
val correctedBin = (binPos * 64.0 + variation2).toInt()
The corrected bin is stored alongside the log-scaled magnitude and the FFT frame number as a Peak(fftPassNumber, peakMagnitude, correctedFrequencyBin).
4

Step 4: Frequency banding

Each accepted peak’s corrected bin is converted to Hz and routed into one of four bands. Peaks outside all bands are discarded.
BandFrequency range
0250–520 Hz
1520–1450 Hz
21450–3500 Hz
33500–5500 Hz
val freqHz = correctedBin * (SAMPLE_RATE.toDouble() / 2.0 / 1024.0 / 64.0)
val band = when {
    freqHz < 250  -> -1   // discard
    freqHz < 520  -> 0
    freqHz < 1450 -> 1
    freqHz < 3500 -> 2
    freqHz <= 5500 -> 3
    else          -> -1   // discard
}
The main processing loop stops consuming audio once both of the following are true:
  • At least MAX_TIME_SECONDS (3.1 s) of audio has been processed
  • At least MAX_PEAKS (255) total peaks have been collected across all bands
This bounds the signature size and processing time regardless of input length.
5

Step 5: Binary encoding

The four per-band peak lists are serialised into the Shazam-compatible binary format. See Signature Format for a complete description of the header layout, band section structure, delta encoding scheme, and embedded CRC32.

Key constants

These constants from SignatureGenerator.companion govern the entire pipeline. They are not configurable — changing them produces signatures incompatible with Shazam’s discovery endpoint.
ConstantValueMeaning
SAMPLE_RATE16000Expected input sample rate (Hz)
FFT_SIZE2048FFT window size (samples)
HOP_SIZE128Samples advanced per frame (~8ms)
FFT_OUTPUT_SIZE1025Magnitude bins per frame (FFT_SIZE / 2 + 1)
MAX_TIME_SECONDS3.1Soft upper bound on audio processed (seconds)
MAX_PEAKS255Maximum total peaks across all bands
RING_SIZE256Depth of the FFT and spread output ring buffers (frames)
companion object {
    const val SAMPLE_RATE       = 16000
    const val FFT_SIZE          = 2048
    const val HOP_SIZE          = 128
    const val FFT_OUTPUT_SIZE   = FFT_SIZE / 2 + 1   // 1025
    const val MAX_TIME_SECONDS  = 3.1
    const val MAX_PEAKS         = 255
    // RING_SIZE = 256 — depth of the FFT and spread ring buffers (private constant)
}
The sparse constellation map is robust because the relative positions of peaks are preserved even when the audio is compressed, equalised, or transmitted over a lossy stream — which is exactly the environment in live radio. Shazam’s matching algorithm identifies songs by looking for constellations of peaks that appear in the same time-frequency arrangement, not by comparing raw waveforms or full spectrograms.

Build docs developers (and LLMs) love