1. This site uses cookies. By continuing to use this site, you are agreeing to our use of cookies. Learn More.
  2. Anuncie Aqui
    Anuncie aqui você Também: fdantas@4each.com.br

[Python] Why does my real-time FFT of a logarithmic sweep keep sinking — and why does this...

Discussão em 'Python' iniciado por Stack, Setembro 13, 2025.

  1. Stack

    Stack Membro Participativo

    I’m building an open-source audio spectrum analyzer for loudspeaker manufacturing. The “killer feature” is using a logarithmic sweep as the excitation signal, and analyzing the response as the sweep is being recorded, so the UI never blocks. Repo: https://github.com/max-bold/Spectrum.

    Everything was peachy… until I tried to make the analysis happen live while the sweep is still running. Then I hit a super annoying effect: as the recorded buffer grows, the plotted spectrum slowly drifts down in level, even though the input level doesn’t change. Classic normalization like this makes it sink:

    yf = np.abs(np.fft.rfft(record))
    yf1 = 2 * yf / len(record) # standard amplitude normalization


    That’s weird because the recorded signal level isn’t changing over time.

    So, I started experimenting. I ended up with a normalization that magically stays stable regardless of the generator parameters:

    yf = np.abs(np.fft.rfft(record))
    yf2 = yf / nk
    nk = np.sqrt(length * rate / np.log10(band[1] / band[0]) / 10) * 10 ** (0.35726 / 20)


    This works frighteningly well. But… why?

    Below is a minimal runnable example showing what I’m doing. It animates two traces (line1 with “textbook” normalization + pink weighting, and line2 with my strange nk normalization + pink weighting). Only the second stays steady while the sweep grows.

    import numpy as np
    import matplotlib.pyplot as plt
    from matplotlib.axes import Axes
    from matplotlib.lines import Line2D

    # Setting sweep params
    rate: int = 96000
    chunksize: int = 4096
    band = np.asarray((50, 2000), np.float64) / rate
    t_end = 30
    length = int(t_end * rate)
    nk = np.sqrt(length * rate / np.log10(band[1] / band[0]) / 10) * 10 ** (0.35726 / 20)

    # Generating sweep
    k = length * band[0] / np.log(band[1] / band[0])
    l = length / np.log(band[1] / band[0])
    t = np.arange(0, length, dtype=np.float64)
    sweep = np.sin(2 * np.pi * k * (np.exp(t / l) - 1))

    # Initialise plt in interactive mode
    plt.ion()
    _, ax = plt.subplots()
    ax: Axes = ax
    ax.grid(True, which="both")
    (line1,) = ax.semilogx([], [])
    (line2,) = ax.semilogx([], [])
    line1: Line2D = line1
    line2: Line2D = line2
    ax.set_xlim(20, 20e3)
    ax.set_ylim(-60, 60)

    for start in range(0, length, chunksize):
    end = min(start + chunksize, int(length))
    chunk = sweep[:end]

    xf = np.fft.rfftfreq(len(chunk), 1 / rate)
    yf = np.abs(np.fft.rfft(chunk))

    # "Textbook" amplitude normalization
    yf1 = 2 * yf / len(chunk)
    pinked_yf1 = yf1 * np.sqrt(xf) # pink weighting to look at ~constant SPL per octave
    log_yf1 = 20 * np.log10(pinked_yf1.clip(1e-12, None))
    line1.set_data(xf, log_yf1)

    # My weird normalization
    yf2 = yf / nk
    pinked_yf2 = yf2 * np.sqrt(xf)
    log_yf2 = 20 * np.log10(pinked_yf2.clip(1e-12, None))
    line2.set_data(xf, log_yf2)

    plt.draw()
    plt.pause(0.1)

    plt.ioff()
    plt.show()

    What I think I understand (maybe?)


    • Using np.sqrt(length * rate) in the denominator feels like heading toward power spectral density (PSD):

      10*log10(|rfft(record)/length/rate|^2) == 20*log10(|rfft(record)| / sqrt(length*rate))


      I can live with that idea. But in my case length == len(sweep), not len(record). The record grows chunk by chunk, but the sweep length is fixed. That mismatch seems to matter.


    • My nk also includes sqrt(1 / log10(band[1]/band[0])). Intuitively, the sweep’s energy density per log-frequency depends on the ratio of fmax/fmin (log sweep spends equal time per octave). So some dependence on log10(band[1]/band[0]) makes sense… but why under a square root?


    • Then there’s a mysterious division by / 10. Where on earth did that creep in? Is that some hidden unit conversion (ln vs log10? octave vs decade? RMS vs peak?), or am I just compensating for something else I broke?


    • Finally, I have to tack on 10 ** (0.35726 / 20) to kill a stubborn 0.35726 dB bias. Yuck. That feels like a smoking gun for a systematic mistake (windowing? bin-to-frequency mapping? FFT scaling convention? pink weighting reference?), but I can’t pin it down.
    The actual questions (send help!)


    1. Why does the “standard” 2*yf/len(record) normalization make the spectrum drift downward as len(record) increases for a logarithmic sweep analyzed in a growing prefix? The signal level isn’t changing, yet the line sinks.


    2. Why does a normalization of the form

      $$ nk \propto \sqrt{\frac{length \cdot rate}{\log_{10}(f_\text{hi}/f_\text{lo})}} $$

      stabilize things? If PSD and sweep energy distribution are the culprits, can someone derive this from first principles (time spent per Hz vs per octave, Parseval, FFT bin width, etc.) and explain why the log-span lands inside a square root?


    3. What is that /10 factor actually correcting? Is it a base-10 vs natural-log artifact, decades vs octaves, RMS vs peak, one-sided FFT vs two-sided energy, or something else?


    4. Where does the stubborn 0.35726 dB offset come from? Is there a known constant when mixing:
      • a one-sided rfft
      • rectangular window on an ever-growing prefix (i.e., a very non-stationary “window”)
      • pink weighting via sqrt(f)
      • log sweep amplitude law …that would explain exactly ~0.35726 dB?
    Constraints & context

    • I’m doing real-time analysis while the sweep is still running. That means each FFT sees a different prefix of a log sweep, not a stationary segment of a stationary process. I know this is hostile to “typical” FFT assumptions (stationarity, periodicity within the window, etc.).
    • I intentionally use a rectangular window (because it’s a log sweep — classic windows smear even more). I’m aware this invites leakage when the instantaneous frequency doesn’t land exactly on a bin — which it never does here.
    • The plotted curves apply a pink weighting (* sqrt(f)) before converting to dB. That’s on purpose for my UX.
    • Sample code and params are above; you can run it and watch line1 sink while line2 stays flat.
    What I’m looking for

    • A derivation (or a pointer to one) for the correct real-time FFT scaling of a growing-prefix logarithmic sweep, explaining the dependence on total sweep duration, sampling rate, and log-frequency span.
    • An explanation for the /10 and the 0.35726 dB gremlin. If these are actually masking deeper mistakes (e.g., wrong reference of pink weighting, decade vs octave constants, ln vs log10, RMS vs peak, one- vs two-sided PSD), I’d love to fix the root cause rather than keep the band-aid.

    I’m officially out of forehead to hit the wall with and have switched to a long, plaintive whine. Any DSP folks who can help me stop hard-coding mystery constants will earn my eternal gratitude (and a crisp PR link in the repo).

    everything a could imagine

    Continue reading...

Compartilhe esta Página