Spectrum analysis is the move I reach for whenever a waveform refuses to explain itself in the time domain. A vibration trace that looks like random chatter suddenly reveals a dominant bearing tone in the spectrum; an IoT microphone stream that seems quiet might show a 60 Hz hum begging for a notch filter. In this piece I walk through how I approach spectrum analysis in Python today, with the same mindset I bring to production diagnostics at work: quick feedback, reproducible notebooks, and code that you can hand to a teammate without a long handoff. You will see where each tool fits, how to pick the right transform, and how to keep artifacts like leakage and aliasing from fooling you. I focus on patterns that hold up in 2026 projects—short scripts, CI-checked notebooks, and GPU-friendly code paths—so you can drop them into your own workflows with confidence.
Why Frequency View Beats Time View in 2026
Time plots tell you “something happened.” Spectra tell you “this frequency caused it.” I reach for spectrum analysis when I need to isolate periodic faults, measure harmonic distortion, or track modulation sidebands. In practice, I gain three things:
- Faster fault isolation: a dominant peak often matches a mechanical speed or carrier tone immediately.
- Quantitative thresholds: I can set limits on amplitude at specific bins instead of eyeballing waveforms.
- Compact features: a dozen spectral bins often beat thousands of time samples in downstream ML models.
Modern workflows add another angle: real-time streaming. Python stacks in 2026 can push a rolling STFT to a dashboard with WebGPU rendering, so the spectral view is no longer a “batch-only” tool. Keeping this mental model up front makes the remaining steps feel purposeful rather than ritual.
Building a Reproducible Analysis Stack
I start every spectrum session with a clean, versioned environment so results match across machines.
- Core packages:
numpy>=2.1,scipy>=1.13,matplotlib>=3.9,pandas,xarrayfor labeled data, andsoundfileorscipy.iofor I/O. - Optional speedups:
numbafor CPU JIT,cupywhen a CUDA GPU is available, andjaxwhen I want automatic differentiation for custom spectral losses. - Notebook hygiene: notebooks live in
nbs/; I pin deps viauv pip freeze > requirements.lockafter each change. A pre-commit hook runsruffandnbstripoutso git diffs stay readable.
A minimal environment bootstrap script I keep around:
# bootstrap_env.py
import subprocess
packages = [
"numpy>=2.1",
"scipy>=1.13",
"matplotlib>=3.9",
"pandas",
"xarray",
"soundfile",
"numba",
"cupy-cuda12x; platform_system==‘Linux‘",
]
subprocess.check_call(["uv", "pip", "install", *packages])
Run with python bootstrap_env.py and you have a consistent base.
Notebook template I reuse
# 00-spectrum-starter.ipynb (pseudocode layout)
import numpy as np
import matplotlib.pyplot as plt
from scipy import signal
from pathlib import Path
DATA = Path("data/raw.wav")
fs, x = ... # load with soundfile.read
xprep, fs = preparesignal(x, fs, target_fs=None, window="hann")
freqs, mag = fftspectrum(xprep, fs)
plot_spectrum(freqs, mag, "Raw FFT")
The point is to keep a repeatable skeleton: load → prep → analyze → plot. Every new project starts from the same shape.
Cleaning and Windowing: Preparing Signals for the FFT
Raw signals rarely meet the assumptions of the FFT. I always tackle three chores first:
1) Detrending and DC removal: subtract the mean or fit a low-order polynomial to remove drift. This keeps energy from smearing into the zero bin.
2) Windowing: choose a taper that matches the measurement goal. Hann for general-purpose low sidelobes, Blackman for aggressive sidelobe suppression, flat-top when amplitude accuracy matters (e.g., measuring THD). Kaiser with β≈8 is my default when I need a tunable compromise.
3) Resampling: align sampling rate with the frequencies you care about. If your phenomenon lives below 10 kHz, downsample a 192 kHz recording with scipy.signal.resample_poly to reduce compute and raise frequency resolution per bin.
Example preprocessing pipeline:
import numpy as np
from scipy import signal
def preparesignal(x, fs, targetfs=None, window="hann"):
x = np.asarray(x)
x = x - np.mean(x) # remove DC
if targetfs and targetfs < fs:
x = signal.resamplepoly(x, up=targetfs, down=fs)
fs = target_fs
win = signal.get_window(window, x.size, fftbins=True)
return x * win, fs
Window choice cheat sheet
- Hann: good general-purpose; ~-31 dB sidelobes; moderate main-lobe width.
- Blackman: ~-58 dB sidelobes; wider main lobe; good for tonal detection in noisy floors.
- Flat-top: amplitude-accurate within ~0.01 dB; very wide main lobe; use when measuring RMS/THD.
- Kaiser β=6–10: tuneable; I use β=8 when balancing leakage vs resolution.
FFT, STFT, Welch, Multitaper: Picking the Right Spectrum
Spectrum analysis is not one tool; it is a family. I pick among them based on the question.
- Plain FFT: best when the signal is stationary and I need raw frequency bins quickly. Complexity O(N log N), minimal overhead.
- Welch’s method: averages multiple windowed segments to reduce variance. I use it for noisy measurements like vibration on factory floors.
- STFT (Short-Time Fourier Transform): adds time localization. Great for speech, bird calls, or any signal whose content drifts over time.
- Multitaper: higher stability for short, noisy signals. The
spectrumpackage ormne.timefrequency.psdarray_multitaperare handy here.
Plain FFT:
import numpy as np
def fft_spectrum(x, fs):
n = x.size
spectrum = np.fft.rfft(x)
freqs = np.fft.rfftfreq(n, 1/fs)
mag = np.abs(spectrum) / n
return freqs, mag
Welch PSD:
from scipy import signal
def welch_psd(x, fs, nperseg=2048, noverlap=None, window="hann"):
freqs, psd = signal.welch(
x, fs=fs, window=window, nperseg=nperseg, noverlap=noverlap,
detrend="constant", scaling="density"
)
return freqs, psd
STFT for evolving content:
from scipy import signal
def stft_mag(x, fs, nperseg=1024, noverlap=512, window="hann"):
freqs, times, Zxx = signal.stft(
x, fs=fs, window=window, nperseg=nperseg, noverlap=noverlap,
detrend=False, boundary=None
)
magnitude = np.abs(Zxx)
return freqs, times, magnitude
Rule of thumb I follow: if variance in the estimate bothers you, pick Welch; if stationarity bothers you, pick STFT; if neither bothers you, plain FFT is fastest. When I need narrow peaks in high noise, multitaper beats the others with minimal parameter fiddling.
Parameter selection grid
- Lowest frequency of interest
flow: choosenpersegso thatfs/nperseg <= flow/4to get at least ~4 bins across the lowest tone. - Hop size for STFT: pick 25%–50% of
npersegto balance overlap vs compute. - Number of averages for Welch: enough so that each average spans at least 10 cycles of the lowest tone; variance drops ~1/num_segments.
Reading Spectra Like a Story
A spectrum is more than peaks. I train juniors to read four features:
- Peak positions: tie them to physical speeds or electrical carriers.
- Harmonics: indicate nonlinearities; even-order hints at asymmetry, odd-order at symmetric distortion.
- Noise floor: broadband elevation points to mechanical noise, preamp hiss, or quantization limits.
- Sidebands: reveal modulation; distance between sidebands equals the modulating frequency.
Plotting helper:
import matplotlib.pyplot as plt
def plot_spectrum(freqs, mag, title, xlim=None, log=False):
plt.figure(figsize=(9,4))
plt.plot(freqs, mag)
if log:
plt.yscale("log")
if xlim:
plt.xlim(xlim)
plt.xlabel("Frequency (Hz)")
plt.ylabel("Magnitude")
plt.title(title)
plt.grid(True, which="both")
plt.tight_layout()
Use linear scale to judge harmonic ratios, log scale to see weak components. I often stack both in the same notebook cell for quick comparisons.
Handling Real-World Mess: Leakage, Aliasing, and SNR
The textbook FFT assumes perfect conditions. Reality fights back; I handle three common problems up front.
Leakage: arises when the signal is not an integer number of cycles within the window. Mitigation: apply a tapered window (Hann/Blackman), or for amplitude-critical work use a flat-top window and correct amplitude by the coherent gain factor from scipy.signal.get_window. For tonal measurements, I sometimes zero-pad 4× to improve peak interpolation visually, though it does not increase true resolution.
Aliasing: if you measure a motor at 25 kHz with a 40 kHz sampler, harmonics above 20 kHz will fold down and masquerade as lower tones. Solution: enforce an anti-alias filter in hardware where possible; in software, always confirm fs is at least twice the highest expected content and prefer oversampling (3–4×) when practical.
Noise and averaging: Welch averaging lowers variance but smears transient tones. I often run both a short-window STFT (captures transients) and a longer-window Welch (stable PSD) on the same data, then compare. If SNR is very low, a multi-taper PSD yields a steadier floor without heavy smoothing.
Dealing with amplitude accuracy
If I care about absolute amplitude (e.g., dBV or g RMS), I apply coherent gain correction: coh_gain = win.sum()/len(win). Divide FFT magnitudes by this factor. Flat-top windows already aim for amplitude accuracy but still benefit from explicit correction for reproducibility.
Interpolating peaks
Zero-padding improves visual resolution but not true bin resolution. For accurate peak frequency I fit a parabola around the max bin (quadratic interpolation). Example:
import numpy as np
def quadratic_peak(freqs, mag):
k = np.argmax(mag)
if k == 0 or k == len(mag)-1:
return freqs[k], mag[k]
y0, y1, y2 = mag[k-1], mag[k], mag[k+1]
p = 0.5(y0 - y2)/(y0 - 2y1 + y2)
freq_interp = freqs[k] + p*(freqs[1]-freqs[0])
mag_interp = y1 - 0.25(y0 - y2)p
return freqinterp, maginterp
Going Beyond 1D: Spectrograms, Ridges, and Reassignment
Many signals evolve. The STFT gives a time-frequency matrix, but standard magnitude plots can blur energy. Two upgrades help:
1) Reassigned spectrogram: shifts energy toward the instantaneous frequency, sharpening ridges. The librosa.reassigned_spectrogram call is handy for audio and bird-song work.
2) Synchrosqueezing: concentrates energy along chirps; ssqueezepy offers a straightforward API for continuous wavelet transforms with synchrosqueezing.
Example with reassignment:
import librosa
import numpy as np
def reassignedspec(x, fs, nfft=2048, hop=256):
Sre, freqs, times = librosa.reassignedspectrogram(
y=x, sr=fs, nfft=nfft, hop_length=hop, window="hann",
ref_power=np.max
)
mag = np.abs(S_re)
return freqs, times, mag
Plot with librosa.display.specshow or matplotlib.pcolormesh. The sharpened ridges make FM sidebands and chirps easier to pick out.
Wavelets vs STFT
I stay with STFT when I want uniform frequency bins and easy overlap-add reconstruction. I reach for wavelets (CWT) when I need better resolution at low frequencies and can tolerate varying bandwidth. Synchrosqueezing bridges both worlds by reassigning energy back to sharper ridges.
Feature Extraction for ML Pipelines
Once a spectrum is clean, I rarely feed raw bins into models. Instead, I extract features that capture physics:
- Band energy ratios (e.g., 0–300 Hz vs 1–5 kHz) for machinery health.
- Harmonic amplitudes normalized to the fundamental for THD calculations.
- Spectral centroid and bandwidth for timbre-like metrics in acoustics.
- Peak frequency tracking over time for speed estimation.
- Crest factor of the magnitude spectrum to flag impulsiveness.
Pandas-friendly extraction:
import pandas as pd
def band_energy(freqs, mag, bands):
rows = []
for name, (f0, f1) in bands.items():
mask = (freqs >= f0) & (freqs < f1)
energy = np.sum(mag[mask]2)
rows.append((name, energy))
return pd.DataFrame(rows, columns=["band", "energy"])
These features slot directly into scikit-learn pipelines or go to a feature store for downstream models.
Labelled arrays with xarray
When I hand spectra to other teams, I wrap them in xarray.DataArray with dimensions freq and time. This carries coordinate labels, making selections like spec.sel(freq=slice(0,500)) trivial. It also serializes cleanly to NetCDF for long-term storage.
Performance and Scaling: GPUs, JIT, and Streaming
Spectral code can saturate CPUs quickly when you batch large files or stream live data. Three tactics keep me within budget:
- Vectorization first: prefer
scipy.signal.stftover manual loops. Avoid Python-level for-loops in the hot path. - Numba JIT: for custom windowed operations, a simple
@njitoften halves runtime on CPU. - GPU paths: with
cupy, most NumPy FFT calls become GPU-accelerated by swappingimport cupy as np. For streaming dashboards, I couple this withplotlyWebGPU orpyqtgraph’s GPU backends.
Example CPU/GPU toggle:
try:
import cupy as xp # GPU
except ImportError:
import numpy as xp # CPU fallback
def fast_rfft(x, axis=-1):
return xp.fft.rfft(x, axis=axis)
For continuous streams, I run a ring buffer plus an STFT worker using asyncio:
import asyncio
import numpy as np
from collections import deque
class RingBuffer:
def init(self, size):
self.buf = deque(maxlen=size)
def extend(self, data):
self.buf.extend(data)
def snapshot(self):
return np.array(self.buf, dtype=float)
async def stft_loop(source, fs):
buffer = RingBuffer(size=10_000)
async for chunk in source():
buffer.extend(chunk)
x = buffer.snapshot()
freqs, times, mag = stft_mag(x, fs)
# publish mag to a dashboard here
await asyncio.sleep(0)
This pattern keeps latency low while avoiding thread contention.
Scaling to many channels
When analyzing dozens of accelerometer channels, I reshape data to (channels, samples) and run batched FFTs: np.fft.rfft(x, axis=-1). On GPU, this lights up all SMs. I also precompute windows and reuse them to avoid repeated allocations. For cloud jobs, I shard by channel across workers and aggregate features, not raw spectra, to keep egress costs down.
Validation: Are You Trusting the Right Peaks?
I do not trust a spectrum until it passes a couple of checks:
- Synthetic sanity: generate a known tone at, say, 1 kHz plus noise, and confirm the code reports the peak at 1 kHz within bin resolution.
- Sampling math: verify that
fs, segment length, and zero-padding produce the expected bin spacingdf = fs / n_fft. - Window correction: when measuring amplitude, divide by the window’s coherent gain from
signal.get_window(window, N, fftbins=True).sum()/Nto avoid under-reporting peaks. - CI check: add a small pytest that builds a tone and asserts the PSD peak location.
import numpy as np
from scipy import signal
def dominant_bin(freqs, mag):
return freqs[np.argmax(mag)]
def testwelchpeak_location():
fs = 10_000
t = np.arange(0, 1, 1/fs)
tone = np.sin(2np.pi1500*t)
freqs, psd = signal.welch(tone, fs=fs, nperseg=2048)
peak = dominant_bin(freqs, psd)
assert abs(peak - 1500) < fs / 2048
This small guard keeps regressions out of production pipelines.
Calibration against hardware
When measuring real sensors, I run a calibration sweep with a signal generator. I record a sine at multiple amplitudes, compute the measured RMS per tone, and fit a linear regression to derive a scale factor. I store that factor alongside SpectrumMeta so later analyses automatically compensate for gain drift.
Traditional vs Modern Workflow
Traditional (2015)
—
Manual pip install, version drift
uv or pipx pinned locks, pre-commit + ruff CPU-only NumPy
cupy/jax paths with same API Static matplotlib PNGs
plotly streaming Ad-hoc notebooks
dvc or lakeFS Raw bins into models
The modern column is not about novelty; it is about keeping spectral insights trustworthy and shareable across a team.
Common Mistakes and How I Avoid Them
- Measuring amplitude without window gain correction: always divide by coherent gain when accuracy matters.
- Forgetting anti-alias filters: if hardware is fixed, simulate the fold-over to know where ghosts may appear.
- Using too-short segments for Welch: if your lowest frequency of interest is 5 Hz, ensure segment length covers several cycles (at least 1 second in this case).
- Ignoring units: store sampling rate and window type alongside every spectrum; I keep a lightweight dataclass for this metadata.
- Over-smoothing spectrograms: large
npersegblurs time detail; match window length to the shortest event you care about. - Misreading dB scales: PSD in dB/Hz is not the same as magnitude dB. Label axes with units to prevent confusion.
Helper dataclass for metadata:
from dataclasses import dataclass
@dataclass
class SpectrumMeta:
fs: float
window: str
method: str
nperseg: int | None = None
overlap: int | None = None
Attach this to saved spectra so plots can be reproduced months later.
Real-World Scenarios
Industrial vibration: I often downsample to 25.6 kHz (standard in many condition monitoring systems), apply a Hann window, and run Welch with 50% overlap to stabilize the noise floor. Peaks that line up at 1×, 2×, 3× running speed scream imbalance or misalignment; sidebands spaced at the blade-pass rate reveal aerodynamic issues. I export band energies into a feature store so the reliability team can track trends daily.
Audio for keyword spotting: a short STFT (25 ms window, 10 ms hop) feeds log-mel features. Before feature extraction, I notch 50/60 Hz mains hum using an IIR notch from scipy.signal.iirnotch to keep the model from wasting capacity on predictable interference. I augment with colored noise to ensure the PSD pipeline is robust to background changes.
Radio telemetry: for narrowband FM, I grab a 1–2 second buffer, apply a Blackman window, and look for sidebands around the carrier spaced by the audio frequencies. A reassigned spectrogram quickly shows if audio content has drifted. When demodulating digitally, I monitor the baseband PSD to catch oscillator drift before frames are lost.
Seismic microtremor: low-frequency (0.1–20 Hz) content demands long windows. I use 30–60 s Hann windows with 75% overlap in Welch to get a smooth H/V curve. I also apply a cosine taper at the edges to minimize leakage from local transients like footsteps.
Power quality: to measure total harmonic distortion on a 60 Hz grid, I use a flat-top window and exact integer-cycle capture (e.g., 10 cycles). I then compute harmonic magnitudes up to the 25th. A small CI test ensures THD computation matches a golden MATLAB script within 0.05 dB.
Machine learning feature ingestion: I maintain a SpectralFeatureExtractor class that outputs a dictionary of band ratios, harmonic levels, centroid, bandwidth, and crest factor. It also stamps the versions of numpy, scipy, and the window used. This makes model retraining reproducible months later.
End-to-End Example: From WAV to Decision
Here is a compact pipeline I drop into new repos.
from pathlib import Path
import soundfile as sf
from scipy import signal
import numpy as np
from dataclasses import dataclass
@dataclass
class SpectrumResult:
freqs: np.ndarray
mag: np.ndarray
meta: SpectrumMeta
def load_audio(path: Path):
x, fs = sf.read(path)
if x.ndim > 1:
x = x.mean(axis=1) # mono
return x, fs
def analyze(path: Path, method="welch"):
x, fs = load_audio(path)
x, fs = preparesignal(x, fs, targetfs=None, window="hann")
if method == "fft":
freqs, mag = fft_spectrum(x, fs)
meta = SpectrumMeta(fs, "hann", "fft")
elif method == "welch":
freqs, mag = welch_psd(x, fs, nperseg=4096, noverlap=2048)
meta = SpectrumMeta(fs, "hann", "welch", nperseg=4096, overlap=2048)
else:
raise ValueError("unknown method")
return SpectrumResult(freqs, mag, meta)
if name == "main":
result = analyze(Path("data/sample.wav"), method="welch")
plot_spectrum(result.freqs, result.mag, "Welch PSD", log=True, xlim=(0, 5000))
This script enforces metadata, keeps window choice explicit, and can be wrapped in a CLI for batch runs.
Parameter Tuning Playbook
When I land on a new dataset, I run this sequence:
1) Plot a short FFT (no averaging) to get a feel for frequencies present.
2) Choose fs sanity: confirm the highest visible content is comfortably below Nyquist.
3) Decide on window length: set nperseg to cover 5–20 cycles of the lowest tone of interest.
4) Check leakage with a pure tone simulation using the chosen window; adjust if amplitude error exceeds tolerance.
5) Run Welch and STFT side by side; ensure the events of interest appear in both without being smoothed away.
6) Extract quick features (band ratios, peak freqs); verify they are stable across nearby windows.
Integrating Filters with Spectral Work
Spectra often inform filters, and filters alter spectra. I keep a small toolbox:
- Design a notch at detected mains hum:
b, a = signal.iirnotch(f0, Q, fs); apply withsignal.filtfilt. - Low-pass before downsampling:
signal.firwinorsignal.cheby1with passband ripple constraints. - Visual verify: plot PSD before/after filter to confirm stopband rejection and passband flatness.
I also measure filter-induced delay. For linear-phase FIR, group delay is (len(h)-1)/2 samples. For IIR, I estimate with signal.group_delay around the band of interest and compensate in downstream alignment.
Phase, Group Delay, and Coherence
Magnitude often steals the spotlight, but phase holds alignment info.
- I unwrap phase with
np.unwrap(np.angle(X))to inspect constant slope, which implies a delay. - Group delay highlights resonances; sudden spikes often coincide with narrow notches or poles.
- For multi-channel diagnostics, I compute magnitude-squared coherence (
signal.coherence) to quantify how related two channels are across frequency. Low coherence outside of line noise bands tells me the sensors are mostly independent; high coherence at harmonics hints at shared mechanical coupling.
Managing Units and Scaling
I store units explicitly: Hz for frequency, linear magnitude, or dBFS/dBV for amplitude, and dB/Hz for PSD. Converting PSD to dB requires 10log10(psd); converting amplitude to dB needs 20log10(mag). Mixing the two is a common source of confusion; axis labels save investigations later.
Data Management: Saving, Versioning, and Replaying
I rarely keep raw spectra only. I save:
- Raw time-domain snippet (compressed FLAC or WAV) so I can replay bugs.
- Spectrum arrays with
np.savez_compressedincludingfreqs,mag, andSpectrumMetafields. - A small JSON manifest referencing git commit hash and dependency lockfile hash. This ties every spectrum to the exact code that produced it.
For large collections, I store features (not full spectra) in Parquet. A weekly job recomputes spectra for a small sample to detect pipeline drift; if feature stats shift, I investigate dependency changes.
Streaming Dashboards That Colleagues Actually Use
I build dashboards with FastAPI + plotly WebGPU front-ends. A background task ingests UDP audio chunks, runs STFT on GPU, and pushes magnitude frames over WebSockets. I include:
- Waterfall view (time on one axis, frequency on the other) with adjustable dB range.
- Peak markers that label top N bins with frequency and amplitude.
- Configurable window/hop sliders so colleagues can tune trade-offs without redeploying.
- A “freeze frame” button to snapshot spectra for later export into the repo.
This keeps spectrum analysis collaborative rather than notebook-only.
Debugging Checklist
When a spectrum looks wrong, I run this in order:
- Confirm
fsmatches the data header; wrong sample rate shifts every peak. - Inspect raw waveform min/max for clipping; hard clipping floods the spectrum with odd harmonics.
- Plot the window itself to ensure correct length and taper.
- Compute bin spacing
df; verify expected peaks land on or near bins; if not, increasenperseg. - Test with a synthetic tone injected into the same pipeline to isolate hardware vs software issues.
Security and Privacy in Audio/Signal Pipelines
If the data include speech or sensitive machine sounds, I strip identifiers early. I avoid storing raw audio in shared buckets; instead I store band-limited or feature-only representations. When sharing spectra publicly, I blur frequencies above 8 kHz for speech to reduce intelligibility while keeping hum/harmonic info.
Practical Edge Cases
- Very short signals (<100 ms): plain FFT may have too few cycles. Multitaper or zero-padding helps visualization; I also compute autocorrelation to verify periodicity before trusting a spectrum.
- Long, drifting recordings: apply a sliding high-pass to remove DC drift before STFT; otherwise the low bins dominate color maps.
- Non-uniform sampling: resample onto a uniform grid with
scipy.signal.resamplebefore FFT, or use Lomb–Scargle (scipy.signal.lombscargle) when resampling would distort sparse data. - Complex baseband IQ data: skip
rfft; use fullfftto preserve negative frequencies. Keep track of complex conjugate symmetry assumptions.
Automation: Makefiles and CI
I keep a Makefile target:
make spectrum SAMPLE=data/sample.wav METHOD=welch
that calls the earlier script and writes outputs/sample-welch.npz plus a PNG. CI runs make test to cover synthetic peak checks and ruff linting. This makes spectra a first-class artifact in the repo, not an ad-hoc notebook cell.
When Not to Use Frequency Analysis
It is tempting to FFT everything. I hold back when:
- The event of interest is a single transient with no periodicity; time-domain features (peak, rise time) matter more.
- The signal is heavily non-stationary and too short for even a small STFT; wavelet scalograms may represent it better.
- Computational budget is tiny (microcontrollers); I may use Goertzel filters for a few target tones instead of a full FFT.
Future-Proofing: 2026 and Beyond
- WebGPU and WASM make browser-side spectral viewers cheap; I design APIs that send magnitude frames, not raw audio, to keep bandwidth low.
- TinyML: I export mel or band-energy features to run keyword or fault detectors on Cortex-M chips. The desktop pipeline and embedded pipeline share the same preprocessing constants to avoid train/serve skew.
- Differentiable DSP: with
jax, I prototype losses on spectra (e.g., penalize harmonics) and backprop into signal generators or filters. Keeping FFT code compatible with JAX’sjitpays off when exploring these ideas.
Closing Thoughts
Spectrum analysis stays the fastest route from “mystery waveform” to “actionable insight.” My approach in 2026 revolves around repeatable environments, explicit windows, GPU-aware paths, and CI-backed correctness checks. Whether I am chasing a bearing fault, debuggings a radio link, or grooming features for a speech model, the same principles apply: prepare the signal, pick the right transform, read the spectrum like a story, and validate before acting. Keep these habits close and your spectra will keep telling the truth when the time plots stay silent.



