Skip to content

spectrum

spectrum

Turn a block of microphone audio into a handful of band energies.

The dictation indicator used to receive one number — the RMS level — which is enough to make a field breathe but not enough to make it behave like an equaliser: a single number cannot say where in the spectrum the energy is, so every part of the display can only ever move together.

This computes that missing information. It is deliberately small and pure: no audio device, no state, no callbacks, so the band layout can be tested without a microphone.

Classes

BandSmoother

BandSmoother(bands: int = DEFAULT_BANDS)

Holds the previous frame so the bands rise fast and fall slowly.

Raw per-block energies flicker at the block rate, which reads as noise rather than as a voice. Kept separate from the analysis so the maths above stays pure.

Source code in src/diapason/desktop/spectrum.py
def __init__(self, bands: int = DEFAULT_BANDS) -> None:
    self.values: List[float] = [0.0] * bands

Functions:

band_edges

band_edges(bands: int = DEFAULT_BANDS) -> List[float]

Band boundaries in Hz, spaced logarithmically.

Linear spacing would give almost every band to the top octaves, where speech has least to say — the display would look busy at the right and dead at the left. Log spacing matches how pitch is actually heard.

Source code in src/diapason/desktop/spectrum.py
def band_edges(bands: int = DEFAULT_BANDS) -> List[float]:
    """Band boundaries in Hz, spaced logarithmically.

    Linear spacing would give almost every band to the top octaves, where
    speech has least to say — the display would look busy at the right and
    dead at the left. Log spacing matches how pitch is actually heard.
    """
    if bands < 1:
        return [MIN_HZ, MAX_HZ]
    ratio = MAX_HZ / MIN_HZ
    return [MIN_HZ * (ratio ** (i / bands)) for i in range(bands + 1)]

band_energies

band_energies(
    samples: Sequence[float],
    sample_rate: int,
    *,
    bands: int = DEFAULT_BANDS,
) -> List[float]

Energy per band, 0–1, from a mono block.

Returns all zeros for anything unusable — a block too short to resolve the lowest band tells us nothing, and inventing values would make the display move for no reason.

Source code in src/diapason/desktop/spectrum.py
def band_energies(
    samples: Sequence[float],
    sample_rate: int,
    *,
    bands: int = DEFAULT_BANDS,
) -> List[float]:
    """Energy per band, 0–1, from a mono block.

    Returns all zeros for anything unusable — a block too short to resolve the
    lowest band tells us nothing, and inventing values would make the display
    move for no reason.
    """
    import numpy as np

    data = np.asarray(samples, dtype="float32").reshape(-1)
    if data.size < 64 or sample_rate <= 0:
        return [0.0] * bands

    # A window is not optional: without one, every block boundary is a step
    # change that smears energy across the whole spectrum, and the top bands
    # light up on silence.
    windowed = data * np.hanning(data.size).astype("float32")
    spectrum = np.abs(np.fft.rfft(windowed))
    freqs = np.fft.rfftfreq(data.size, d=1.0 / sample_rate)

    edges = band_edges(bands)
    out: List[float] = []
    for i in range(bands):
        lo, hi = edges[i], edges[i + 1]
        mask = (freqs >= lo) & (freqs < hi)
        if not mask.any():
            # The block is too short to resolve this band; carry the
            # neighbour rather than punching a permanent hole in the display.
            out.append(out[-1] if out else 0.0)
            continue
        out.append(float(spectrum[mask].mean()))

    # Normalise against the loudest band of this block, then apply a gentle
    # compression: raw magnitudes span orders of magnitude, and a linear map
    # leaves everything but the peak flat on the floor.
    peak = max(out) if out else 0.0
    if peak <= 1e-9:
        return [0.0] * bands
    return [min(1.0, (value / peak) ** 0.6) for value in out]

scaled_by_level

scaled_by_level(
    bands: Sequence[float], level: float
) -> List[float]

Scale per-block bands by overall loudness.

Band energies are normalised within their own block, so silence and a shout produce the same shape. Multiplying by the level is what makes the display fall quiet when the room is quiet — the shape says what is being said, the level says how loudly.

Source code in src/diapason/desktop/spectrum.py
def scaled_by_level(bands: Sequence[float], level: float) -> List[float]:
    """Scale per-block bands by overall loudness.

    Band energies are normalised within their own block, so silence and a
    shout produce the same shape. Multiplying by the level is what makes the
    display fall quiet when the room is quiet — the shape says *what* is being
    said, the level says *how loudly*.
    """
    # Same curve the indicator already used for the RMS meter, so a given
    # loudness moves the display by the same amount it always did.
    norm = min(1.0, math.sqrt(max(0.0, level) / 4.0))
    return [value * norm for value in bands]