Qu

Time-Frequency & Wavelets

The idea

An ambulance siren does not have a frequency. It has a frequency *right now*, a different one a second ago, and a different one a second from now — the rising-then-falling wail your ear uses to tell whether it is approaching or receding. Ask "what frequency is the siren" and there is no single number to give back.

Lesson 5 closed on exactly this gap: the DFT, the FFT, poles sitting fixed on the s-plane or z-plane all describe signals and systems that hold still. A single Fourier transform of a whole recording collapses every instant into one spectrum, discarding whatever timing distinguished an early frequency from a later one in the same step that built the frequency axis.

fs = 100
N = 200
t = (0 to N - 1) / fs
low_then_high = cat(1, cos(2 * pi * 4 * t[0:99]), cos(2 * pi * 18 * t[100:199]))
figure()
subplot(1, 2, 1)
plot(t, low_then_high)
xlabel("time (s)")
ylabel("amplitude")
mag = abs(rfft(low_then_high))
f = (0 to length(mag) - 1) * fs / N
subplot(1, 2, 2)
plot(f, mag)
xlabel("frequency (Hz)")
ylabel("magnitude")
output
· plot: figure 1 (200 points)
· plot: figure 2 (101 points)
-1.0 -0.8 -0.6 -0.4 -0.2 0 0.2 0.4 0.6 0.8 1.0 0 0.6 1.2 1.8 time (s) amplitude 0 5 10 15 20 25 30 35 40 45 50 0 15 30 45 frequency (Hz) magnitude

A 4 Hz tone followed by an 18 Hz tone (left) produces a spectrum (right) with two peaks and no indication that one happened before the other — the same signal, played in the opposite order, would give an identical picture.

The fix: instead of transforming the whole recording at once, slide a short window along it and take a separate spectrum of each slice. Line the spectra up side by side — time across, frequency up, brightness for energy — and you get a two-dimensional picture instead of a single curve. This is the short-time Fourier transform (STFT); the picture it produces is a spectrogram:

\[ X(t, f) = \int x(\tau)\, w(\tau - t)\, e^{-j2\pi f \tau}\, d\tau \]

w(\tau - t) is the window, large near \(\tau = t\) and fading to zero away from it, sliding as t advances. Discretely this is nothing more exotic than a windowed FFT computed once per hop across the recording.

The window length is not a free choice. Dennis Gabor showed, in 1946, that time resolution and frequency resolution trade off against each other by a fixed lower bound that no window shape escapes: a narrow window localizes *when* well and *what* poorly; a wide window does the reverse.

Wavelets are the standard answer to wanting both at once — short windows for high frequencies, which change quickly and need fine time resolution, and long windows for low frequencies, which change slowly and need fine frequency resolution instead. A single family of basis functions is stretched and shifted rather than kept at one fixed width:

\[ W(a, b) = \frac{1}{\sqrt{|a|}} \int x(t)\, \psi\!\left(\frac{t - b}{a}\right) dt \]

a scales the wavelet \(\psi\) (wide a for low frequencies, narrow a for high ones) and b slides it along the signal, giving a multi-resolution picture rather than one fixed window size.

In Qu

Run a real sweep through the same whole-signal FFT to make the problem concrete, then fix it:

fs = 200
n = 400
x = chirp(5, 45, fs, n)
whole = abs(rfft(x))
print("magnitude at 5, 15, 25, 35, 45 Hz: {whole[10]:.2f}, {whole[30]:.2f}, {whole[50]:.2f}, {whole[70]:.2f}, {whole[90]:.2f}")
output
magnitude at 5, 15, 25, 35, 45 Hz: 11.75, 23.49, 21.52, 23.66, 11.11
magnitude at 5, 15, 25, 35, 45 Hz: 11.75, 23.49, 21.52, 23.66, 11.11

chirp sweeps linearly from 5 Hz to 45 Hz. All five frequencies show substantial energy and none dominates — technically correct, since the sweep passed through all of them, and useless for the question that actually matters: in what order. Qu's spectrogram performs the windowed-FFT framing directly and hands back the underlying data alongside the plot it draws:

figure()
r = spectrogram(x, 64, 32, fs)
print("db shape = {shape(r.db)}")

peak_rows = argmax(r.db, axis = 0)
peak_freqs = r.freq[peak_rows]
print("peak_freqs = {peak_freqs:.2f}")
print("first frame: {peak_freqs[0]:.2f} Hz, last frame: {peak_freqs[length(peak_freqs) - 1]:.2f} Hz")
output
db shape = [33, 11]
peak_freqs = [9.375, 12.5, 15.625, 18.75, 21.875, 25, 28.125, 31.25, ... (11 elements)]
first frame: 9.38 Hz, last frame: 40.62 Hz
100, 0: -77.914 -78.988 100, 0.32: -96.53 -75.819 100, 0.64: -69.454 -69.05 100, 0.96: -80.788 -69.792 100, 1.28: -66.439 -83.456 100, 1.6: -66.496 -77.68 -78.232 -83.931 -75.203 -69.327 -68.916 -78.547 -69.518 -66.298 -79.016 -66.29 -77.024 -76.487 -77.988 -73.707 -68.955 -68.52 -74.987 -68.751 -65.879 -74.153 -65.683 -76.061 -74.482 -74.323 -71.887 -68.355 -67.881 -71.918 -67.616 -65.191 -70.547 -64.709 -74.904 -72.53 -71.585 -70.034 -67.552 -67.023 -69.319 -66.232 -64.246 -67.596 -63.406 -73.634 -70.696 -69.326 -68.238 -66.571 -65.97 -67.015 -64.682 -63.056 -64.963 -61.806 -72.298 -68.966 -67.34 -66.505 -65.433 -64.744 -64.88 -63.005 -61.631 -62.466 -59.927 -70.917 -67.311 -65.515 -64.814 -64.153 -63.358 -62.826 -61.214 -59.977 -59.988 -57.769 75, 0: -69.499 -65.7 75, 0.32: -63.781 -63.141 75, 0.64: -62.739 -61.821 75, 0.96: -60.788 -59.304 75, 1.28: -58.091 -57.441 75, 1.6: -55.312 -68.04 -64.106 -62.091 -61.46 -61.194 -60.135 -58.718 -57.256 -55.963 -54.751 -52.521 -66.534 -62.505 -60.408 -59.746 -59.514 -58.295 -56.57 -55.043 -53.571 -51.841 -49.333 -64.971 -60.875 -58.703 -57.975 -57.689 -56.287 -54.3 -52.627 -50.877 -48.623 -45.657 -63.339 -59.197 -56.949 -56.123 -55.706 -54.095 -51.862 -49.958 -47.824 -44.987 -41.355 -61.624 -57.448 -55.12 -54.162 -53.543 -51.689 -49.2 -46.969 -44.324 -40.781 -36.215 -59.811 -55.609 -53.19 -52.061 -51.17 -49.03 -46.245 -43.565 -40.246 -35.78 -29.889 -57.88 -53.654 -51.127 -49.784 -48.547 -46.062 -42.902 -39.61 -35.379 -29.627 -21.794 50, 0: -55.81 -51.556 50, 0.32: -48.897 -47.284 50, 0.64: -45.619 -42.702 50, 0.96: -39.035 -34.893 50, 1.28: -29.38 -21.729 50, 1.6: -11.037 -53.575 -49.28 -46.454 -44.5 -42.305 -38.828 -34.436 -29.07 -21.662 -11.178 2.807 -51.142 -46.784 -43.738 -41.348 -38.484 -34.245 -28.767 -21.555 -11.312 2.503 17.117 -48.471 -44.011 -40.668 -37.704 -33.97 -28.631 -21.447 -11.428 2.197 16.782 23.754 -45.505 -40.882 -37.125 -33.38 -28.449 -21.421 -11.543 1.894 16.439 23.72 19.52 -42.171 -37.284 -32.924 -28.065 -21.371 -11.687 1.584 16.089 23.679 19.777 5.437 -38.359 -33.041 -27.761 -21.213 -11.818 1.265 15.729 23.63 20.027 5.798 -8.925 -33.902 -27.863 -21.1 -11.908 0.95 15.363 23.574 20.268 6.165 -8.516 -20.251 25, 0: -28.525 -21.236 25, 0.32: -12.026 0.637 25, 0.64: 14.991 23.511 25, 0.96: 20.5 6.546 25, 1.28: -8.099 -19.753 25, 1.6: -28.746 -21.74 -12.269 0.302 14.609 23.438 20.724 6.936 -7.658 -19.277 -28.079 -35.378 -12.698 -0.08 14.214 23.359 20.942 7.326 -7.171 -18.815 -27.515 -34.453 -40.828 -0.516 13.811 23.276 21.15 7.725 -6.701 -18.19 -27.112 -33.777 -39.518 -45.53 13.415 23.187 21.345 8.148 -6.289 -17.596 -26.229 -33.648 -38.695 -43.628 -49.814 23.091 21.527 8.595 -5.845 -17.358 -25.304 -32.28 -39.289 -42.574 -46.949 -54.018 21.701 9.035 -5.174 -17.318 -25.538 -30.61 -36.93 -44.857 -45.498 -49.501 -58.665 9.175 -3.883 -16.126 -27.553 -31.638 -33.837 -40.198 -51.964 -47.379 -51.167 -65.092 0, 0: -0.47 -9.906 0, 0.32: -21.832 -49.603 0, 0.64: -34.476 -34.937 0, 0.96: -41.465 -68.12 0, 1.28: -48.038 -51.756 0, 1.6: -76.083 100 75 50 25 0 0 0.32 0.64 0.96 1.28 1.6
db shape = [33, 11]
peak_freqs = [9.375, 12.5, 15.625, 18.75, 21.875, 25, 28.125, 31.25, ... (11 elements)]
first frame: 9.38 Hz, last frame: 40.62 Hz

Eleven frames, each a 64-sample slice, and the loudest frequency per frame climbs steadily from 9.4 Hz to 40.6 Hz — coarse, at 3.125 Hz per bin, but tracking the sweep in order. This is what the whole-signal FFT could not give at any resolution.

The window length here, 64 samples, was not free. Try both extremes on the same chirp:

figure()
short = spectrogram(x, 16, 8, fs)
figure()
long  = spectrogram(x, 128, 64, fs)
print("short: {shape(short.db)}, resolution {short.freq[1] - short.freq[0]:.3f} Hz")
print("long:  {shape(long.db)}, resolution {long.freq[1] - long.freq[0]:.3f} Hz")

sp = short.freq[argmax(short.db, axis = 0)]
lp = long.freq[argmax(long.db, axis = 0)]
print("short-window peaks: {sp:.2f}")
print("long-window peaks: {lp:.2f}")
output
short: [9, 49], resolution 12.500 Hz
long:  [65, 5], resolution 1.562 Hz
short-window peaks: [0, 12.5, 0, 12.5, 12.5, 0, 0, 12.5, ... (49 elements)]
long-window peaks: [10.9375, 17.1875, 25, 31.25, 37.5]
100, 0: -91.05 100, 0.32: -81.131 100, 0.64: -84.257 100, 0.96: -76.972 100, 1.28: -72.508 -90.907 -81.095 -84.056 -76.911 -72.468 -90.501 -80.986 -83.5 -76.73 -72.35 -89.889 -80.808 -82.69 -76.436 -72.153 -89.138 -80.563 -81.734 -76.039 -71.878 -88.305 -80.256 -80.711 -75.553 -71.528 -87.432 -79.892 -79.67 -74.989 -71.103 -86.546 -79.476 -78.634 -74.359 -70.605 -85.66 -79.012 -77.615 -73.672 -70.036 -84.784 -78.506 -76.617 -72.936 -69.396 -83.919 -77.961 -75.637 -72.157 -68.688 -83.067 -77.38 -74.673 -71.338 -67.911 -82.225 -76.766 -73.72 -70.482 -67.066 -81.392 -76.12 -72.774 -69.59 -66.152 -80.566 -75.445 -71.83 -68.661 -65.169 -79.743 -74.742 -70.884 -67.696 -64.113 75, 0: -78.92 75, 0.32: -74.01 75, 0.64: -69.931 75, 0.96: -66.692 75, 1.28: -62.983 -78.096 -73.249 -68.967 -65.647 -61.775 -77.267 -72.46 -67.988 -64.557 -60.483 -76.43 -71.641 -66.989 -63.42 -59.101 -75.583 -70.791 -65.966 -62.229 -57.621 -74.724 -69.909 -64.913 -60.98 -56.032 -73.849 -68.993 -63.827 -59.665 -54.323 -72.956 -68.041 -62.702 -58.278 -52.476 -72.042 -67.05 -61.532 -56.809 -50.473 -71.105 -66.017 -60.31 -55.246 -48.287 -70.14 -64.939 -59.03 -53.578 -45.887 -69.146 -63.813 -57.683 -51.786 -43.231 -68.118 -62.632 -56.259 -49.851 -40.263 -67.052 -61.393 -54.748 -47.748 -36.912 -65.946 -60.089 -53.135 -45.445 -33.082 -64.792 -58.712 -51.405 -42.899 -28.651 50, 0: -63.588 50, 0.32: -57.255 50, 0.64: -49.537 50, 0.96: -40.057 50, 1.28: -23.475 -62.327 -55.706 -47.507 -36.845 -17.426 -61.001 -54.054 -45.283 -33.169 -10.47 -59.604 -52.282 -42.824 -28.906 -2.772 -58.126 -50.374 -40.077 -23.907 5.262 -56.557 -48.304 -36.972 -18.033 13.024 -54.885 -46.043 -33.414 -11.229 19.764 -53.092 -43.552 -29.281 -3.63 24.625 -51.162 -40.781 -24.424 4.384 26.758 -49.07 -37.662 -18.696 12.219 26.114 -46.786 -34.105 -12.02 19.127 22.679 -44.274 -29.99 -4.504 24.232 16.859 -41.484 -25.173 3.499 26.662 9.493 -38.35 -19.501 11.408 26.314 1.434 -34.786 -12.884 18.471 23.179 -6.588 -30.678 -5.403 23.807 17.557 -14.03 25, 0: -25.885 25, 0.32: 2.613 25, 0.64: 26.535 25, 0.96: 10.307 25, 1.28: -20.594 -20.256 10.596 26.483 2.304 -26.228 -13.693 17.8 23.648 -5.707 -31.03 -6.257 23.348 18.236 -13.16 -35.152 1.751 26.377 11.122 -19.731 -38.732 9.782 26.621 3.185 -25.344 -41.882 17.105 24.086 -4.829 -30.086 -44.687 22.859 18.895 -12.349 -34.102 -47.209 26.19 11.931 -19.04 -37.527 -49.493 26.726 4.08 -24.822 -40.465 -51.57 24.492 -3.902 -29.801 -42.986 -53.459 19.545 -11.413 -34.167 -45.133 -55.162 12.716 -18.065 -38.132 -46.923 -56.669 4.859 -23.687 -41.914 -48.352 -57.945 -3.188 -28.2 -45.734 -49.402 -58.936 -10.24 -31.361 -49.595 -50.048 -59.572 0, 0: -13.522 0, 0.32: -32.561 0, 0.64: -51.789 0, 0.96: -50.267 0, 1.28: -59.793 100 75 50 25 0 0 0.32 0.64 0.96 1.28
short: [9, 49], resolution 12.500 Hz
long:  [65, 5], resolution 1.562 Hz
short-window peaks: [0, 12.5, 0, 12.5, 12.5, 0, 0, 12.5, ... (49 elements)]
long-window peaks: [10.9375, 17.1875, 25, 31.25, 37.5]

The 16-sample window gives 49 time frames but only 12.5 Hz frequency resolution — so coarse that the tracked peak jumps uselessly between 0 and 12.5 Hz instead of tracing a sweep. The 128-sample window gives a clean, believable rise (10.9 to 37.5 Hz) at 1.6 Hz resolution, but only 5 frames to describe 400 samples of sweep, throwing away the timing precision the short window had. This is Gabor's bound, not a setting to tune away.

Qu's own wavelet support is real but narrow, and worth stating plainly: dwt/idwt implement exactly one wavelet family, the Haar wavelet — the simplest orthogonal wavelet there is, and the only one shipped so far. Daubechies, Morlet, and the rest are not implemented; nothing quietly stands in for them.

x = [1, 2, 3, 4]
D = dwt(x)
approx = D[0, :]
detail = D[1, :]
print("approx = {approx:.6f}")
print("detail = {detail:.6f}")

xr = idwt(D)
print("reconstructed = {xr}")
print("max reconstruction error = {max(abs(xr - x)):.2e}")
output
approx = [2.12132, 4.949747]
detail = [-0.707107, -0.707107]
reconstructed = [1, 2, 3, 4]
max reconstruction error = 8.88e-16
approx = [2.12132, 4.949747]
detail = [-0.707107, -0.707107]
reconstructed = [1, 2, 3, 4]
max reconstruction error = 8.88e-16

One level of Haar splits a length-4 signal into a length-2 "approx" band (each pair's sum, scaled by \(1/\sqrt{2}\)) and a length-2 "detail" band (each pair's difference) — orthogonal, so idwt reconstructs the original exactly, to floating-point precision. Run it again on the approx band and you get a coarser decomposition layered on top, the genuine multi-resolution structure a fixed STFT window cannot give. What one wavelet shape does not buy you is the smoothness and frequency selectivity real audio and image work usually wants — Haar's sharp rectangular shape shows up as blocky artifacts wherever a signal is smooth. Treat today's dwt as a correct, honestly limited first tool, not a finished wavelet library.

You can now see *what* is in a signal (Lesson 4), *whether a system stays under control* (Lesson 5), and *when* content occurs (this lesson). None of that has let you act — remove the hum, keep the speech, reject the interference. Every transform so far has been a way of looking. Lesson 7 starts building the tools that reach into a signal and change it.