Streaming Spectrograms¶
A spectrogram turns a stream into rows: one spectrum every hop samples,
each over the nfft samples that start there. dp_spectrogram does that for
a stream that arrives in pieces of any size — a socket's datagrams, a file
reader's blocks, a ring's frames — and gives the same rows however the
stream was cut. Feed it a sample at a time or a second at a time; the rows
are identical, bit for bit.
Use it when you want every row: a waterfall display, a capture plotted
end to end, a detector scanning time and frequency. If you want one averaged
spectrum instead, that is PSD; a spectrogram row is
the PSD of its frame, unaveraged.
The C face is #include "doppler/spectrogram/spectrogram_core.h" and link
libdoppler. The Python face is doppler.spectral.Spectrogram, below.
In Python¶
push returns the rows a chunk completes as a (rows, nfft) matrix, or
None when it completes none. flush ends the stream the same way: the
zero-padded partial frame as a (1, nfft) matrix, or None when no frame is
owed. The two faces agree on the rows.
import numpy as np
from doppler.spectral import Spectrogram
sp = Spectrogram(nfft=256, hop=256, window="blackman-harris")
x = np.exp(2j * np.pi * 32 * np.arange(4096) / 256).astype(np.complex64)
rows = []
for i in range(0, len(x), 37): # chunks of any size give the same rows
block = sp.push(x[i : i + 37])
if block is not None:
rows.append(block)
tail = sp.flush() # the trailing partial frame, if one is owed
if tail is not None:
rows.append(tail)
waterfall = np.concatenate(rows)
assert waterfall.shape == (16, 256)
assert waterfall[:, 256 // 2 + 32].min() > 0.99 # a unit tone reads 1.0
The object says how it was built: nfft, hop, window, beta and mode
read back, window and mode as the strings that made them. hop defaults
to 0, which means nfft, so the default tiles the stream at any nfft:
sp = Spectrogram(nfft=1024, window="kaiser", beta=7.5)
assert (sp.nfft, sp.hop, sp.window, sp.mode) == (1024, 1024, "kaiser", "power")
A chunk's output buffer is sized for every row the chunk can complete, so
push takes all of its input and consumed equals the chunk length. To bound
memory, push smaller chunks, sized with rows_for. A caller that supplies its
own out must give it at least push_max_out(len(x)) elements: a smaller
out is refused with ValueError, not taken as a short push. That is the
Python face today; the jm gap is jm#2230.
One object is not thread-safe: do not push one Spectrogram from two threads
at once. push releases the GIL, so other threads run, but not on the same
object.
A state blob restores only into an object of the same mode, hop, nfft and
window (and the same Kaiser beta). Any other configuration is refused with
ValueError and the object is unchanged: see the mode note in
Checkpoint and resume.
A waterfall from a stream that arrives in pieces¶
The program below makes a tone that hops between four frequencies with the
library's LO, delivers it in chunks of 1, 37, 700 and 5 samples, and builds
the waterfall two ways, which must agree bit for bit. Every row that lies
inside one frequency segment is checked: its peak is that segment's tone, at
full scale. The rows are linear power, so it converts only that one bin to
dB, the way a display converts only what it draws. It is the tested example
(make test-examples-c), included whole.
/**
* spectrogram_demo.c — a waterfall from a stream that arrives in pieces.
*
* A receiver hands a spectrogram whatever it has: a socket's datagrams, a
* file reader's blocks, a ring's frames. Here the stream is a tone that hops
* between four frequencies, made by the library's own LO, and it arrives in
* chunks of 1, 37, 700 and 5 samples -- none of them a frame. The spectrogram
* turns it into rows of NFFT bins, one every HOP samples, and the rows are
* the same however the stream was cut.
*
* Who owns the output buffer is the caller's choice, and there are two
* honest ones; this program does both and requires them to agree bit for bit:
*
* 1. size every push with dp_spectrogram_push_max_out(): the push then
* takes its whole chunk, every time (this is what a binding does);
* 2. keep one fixed buffer with room for a few rows: a push stops at a
* whole row when it is full, and dp_spectrogram_consumed() says where
* to resume -- nothing is ever dropped.
*
* The rows are linear power, the default: a full-scale tone on a bin reads
* 1.0. A display converts to dB only the bins it draws, and so does this
* program: every row that lies inside one frequency segment is checked
* physically, its peak on the bin of that segment's tone at 0 dBFS, and
* that one bin is the only one it converts. Converting every bin of every
* row is most of a dB row's cost (docs/design/spectrogram-measurements.md,
* entry 5.8), which is why dB rows are asked for by name. Then
* dp_spectrogram_flush() ends the stream with the one zero-padded row the
* last samples still owe.
*
* Build:
* make build
* ./build/native/examples/spectrogram_demo
*/
#include "doppler/lo/lo_core.h"
#include "doppler/spectrogram/spectrogram_core.h"
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define CHECK(cond) \
do \
{ \
if (!(cond)) \
{ \
fprintf (stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \
return 1; \
} \
} \
while (0)
enum
{
NFFT = 256, /* bins per row, and samples per frame */
HOP = 64, /* a row every 64 samples: 75% overlap */
SEG = 2048, /* samples at each frequency */
NSEG = 4, /* frequencies the tone hops between */
TAIL = 100, /* a ragged end, so the stream owes a last row */
TOTAL = NSEG * SEG + TAIL,
ROOM = 3 /* rows the fixed buffer of way 2 can hold */
};
/* The tone's bin in each segment (negative: below the carrier). */
static const int BIN[NSEG] = { 20, -50, 90, 5 };
/* The chunk sizes the "socket" delivers, cycled. */
static const size_t CHUNK[] = { 1, 37, 700, 5 };
#define NCHUNK (sizeof CHUNK / sizeof *CHUNK)
/* A power bin in dBFS: the conversion a display makes for what it draws. */
static float
to_db (float power)
{
return 10.0f * log10f (power);
}
/* Peak index of one row (power and its dB rank the bins alike). */
static size_t
peak (const float *row)
{
size_t p = 0;
for (size_t i = 1; i < NFFT; i++)
if (row[i] > row[p])
p = i;
return p;
}
int
main (void)
{
/* The stream: the LO at each segment's frequency in turn. */
float _Complex *x = (float _Complex *)malloc (TOTAL * sizeof *x);
dp_lo_state_t *lo = dp_lo_create (0.0);
CHECK (x != NULL && lo != NULL);
for (int s = 0; s < NSEG; s++)
{
dp_lo_set_norm_freq (lo, (double)BIN[s] / NFFT);
dp_lo_steps (lo, SEG, x + s * SEG, SEG);
}
dp_lo_steps (lo, TAIL, x + NSEG * SEG, TAIL);
dp_lo_destroy (lo);
/* Hann window, power rows (the default), DC-centred (bin k at
NFFT/2 + k). */
dp_spectrogram_state_t *a = dp_spectrogram_create (
NFFT, HOP, DP_PSD_WINDOW_HANN, 0.0f, DP_SPECTROGRAM_POWER);
dp_spectrogram_state_t *b = dp_spectrogram_create (
NFFT, HOP, DP_PSD_WINDOW_HANN, 0.0f, DP_SPECTROGRAM_POWER);
CHECK (a != NULL && b != NULL);
/* rows_for is exact: how many rows the whole stream makes, before a
sample of it has been pushed. */
const size_t rows = dp_spectrogram_rows_for (a, TOTAL);
CHECK (rows == (TOTAL - NFFT) / HOP + 1);
float *fall_a = (float *)malloc (rows * NFFT * sizeof *fall_a);
float *fall_b = (float *)malloc (rows * NFFT * sizeof *fall_b);
CHECK (fall_a != NULL && fall_b != NULL);
/* Way 1: room for exactly what this chunk completes. Often that is no
room at all -- a 1-sample chunk rarely completes a row -- and the push
still takes the whole chunk, into the carry. */
size_t made_a = 0, off = 0;
for (size_t k = 0; off < TOTAL; k++)
{
size_t n = CHUNK[k % NCHUNK];
if (n > TOTAL - off)
n = TOTAL - off;
size_t room = dp_spectrogram_push_max_out (a, n);
made_a += dp_spectrogram_push (a, x + off, n, fall_a + made_a, room);
CHECK (dp_spectrogram_consumed (a) == n);
off += n;
}
/* Way 2: one buffer, ROOM rows. A 700-sample chunk completes about ten
rows, so the push stops when the buffer is full; the caller copies the
rows out and offers the rest of the chunk again. */
float buf[ROOM * NFFT];
size_t made_b = 0, short_pushes = 0;
off = 0;
for (size_t k = 0; off < TOTAL; k++)
{
size_t n = CHUNK[k % NCHUNK];
if (n > TOTAL - off)
n = TOTAL - off;
for (size_t done = 0; done < n;)
{
size_t w = dp_spectrogram_push (b, x + off + done, n - done, buf,
sizeof buf / sizeof *buf);
memcpy (fall_b + made_b, buf, w * sizeof *buf);
made_b += w;
size_t took = dp_spectrogram_consumed (b);
short_pushes += took < n - done;
done += took;
}
off += n;
}
/* The same rows either way, and every one of them. */
CHECK (made_a == rows * NFFT && made_b == rows * NFFT);
CHECK (memcmp (fall_a, fall_b, rows * NFFT * sizeof *fall_a) == 0);
CHECK (short_pushes > 0); /* way 2 really did run out of room */
/* Physics: a row inside one segment peaks on that segment's tone, at the
tone's true level: 1.0 in power, 0 dBFS once that one bin is converted.
A row straddling a hop holds two tones; skip it. */
size_t checked = 0;
for (size_t r = 0; r < rows; r++)
{
size_t first = r * HOP, last = first + NFFT - 1;
if (first / SEG != last / SEG || last >= NSEG * SEG)
continue;
const float *row = fall_a + r * NFFT;
size_t p = peak (row);
CHECK (p == (size_t)(NFFT / 2 + BIN[first / SEG]));
CHECK (fabsf (to_db (row[p])) < 0.01f); /* 0 dBFS */
checked++;
}
CHECK (checked > rows / 2);
/* End of stream: the samples after the last full row are still owed a
row, on the hop grid (it starts at rows * HOP, zero-padded past the
end). Both ways owe the same one. */
float last_a[NFFT], last_b[NFFT];
CHECK (dp_spectrogram_pending (a) > 0);
CHECK (dp_spectrogram_flush (a, last_a, NFFT) == (int64_t)NFFT);
CHECK (dp_spectrogram_flush (b, last_b, NFFT) == (int64_t)NFFT);
CHECK (memcmp (last_a, last_b, sizeof last_a) == 0);
/* Physics for the flushed row too: it starts at rows * HOP, inside the
last segment, whose tone runs on to the end of the stream; the zeros
past the end fall under the window's last NFFT - (TOTAL - rows * HOP)
taps, Hann's smallest. So it peaks on that tone, just under 0 dBFS. */
CHECK (rows * HOP >= (NSEG - 1) * SEG);
size_t lp = peak (last_a);
CHECK (lp == (size_t)(NFFT / 2 + BIN[NSEG - 1]));
CHECK (to_db (last_a[lp]) <= 0.0f && to_db (last_a[lp]) > -0.5f);
CHECK (dp_spectrogram_flush (a, last_a, NFFT) == 0); /* the stream is over */
printf ("spectrogram: %d samples in chunks of 1/37/700/5 -> %zu rows of "
"%d bins (hop %d) + 1 flushed\n",
TOTAL, rows, NFFT, HOP);
printf (" %zu single-tone rows peak on their tone at 0 dBFS; a %d-row "
"buffer that ran out of room %zu times gave the same rows\n",
checked, ROOM, short_pushes);
dp_spectrogram_destroy (a);
dp_spectrogram_destroy (b);
free (fall_a);
free (fall_b);
free (x);
return 0;
}
Rows¶
Row k covers stream samples [k·hop, k·hop + nfft). With hop == nfft
the rows tile the stream; with hop < nfft they overlap, and nfft / hop
rows are computed per nfft samples. Each costs one FFT, so a quarter hop
costs about four times as much per input sample.
A row is nfft floats of linear power by default
(DP_SPECTROGRAM_POWER), against PSD's full-scale reference: a full-scale
tone on a bin reads 1.0 whatever the window. dB rows are asked for by
name, DP_SPECTROGRAM_DB, and read 0 dBFS for the same tone; both are
PSD's readings of one power, so they cannot disagree about the reference.
Power is the default because converting every bin to dB is most of a dB
row's cost, 70–83% of it
(entry 5.8).
A display converts only the bins it draws, at display precision, and a
consumer that averages rows folds power rows with AccTrace: the mean of
dB rows is not the dB of the mean.
Rows are DC-centred exactly as PSD
emits them, bin k at index nfft/2 + k with negative frequencies first;
bin order has one home, PSD's kernel, so there is no option to reorder it
here (#1988). The
window is PSD's index: 0 Hann, 1 Kaiser (with beta), 2 Blackman-Harris,
3 rectangular.
In a dB row, a bin reads no lower than −200 dB: PSD clamps power before
the logarithm, so an all-zero frame and a signal under the floor give the
same row. Noise reaches it about 10·log10(nfft) sooner than a tone (at
nfft 1024, a total of −180 dBFS leaves on average a handful of the 1024
bins above it). A power row has no floor but float32's, and an all-zero
frame reads exactly 0 there, so to tell digital zero from a very quiet
signal, take power rows
(the measurement).
nfft must be the PSD's transform length for that frame, which is to say a
power of two of at least 2, and 1 <= hop <= nfft; a hop of 0 means nfft,
as it does for PSD, and is the Python default. The window must have gain
at that length: nfft = 2 with Hann is refused, because the symmetric
two-point Hann is [0, 0]. A frame length that is
not a power of two would be zero-padded by the PSD to more bins than samples
(#1966). The mode is
one of the two; any other value is refused.
Sizing the output¶
dp_spectrogram_push writes whole rows only, and returns the number of
floats it wrote. There are two honest ways to own the buffer, and the
example does both:
- Size each push.
dp_spectrogram_push_max_out(s, n)is exactly the room that lets a push take allnsamples:rows_for(s, n) * nfft. It is often 0, because a short chunk completes no row, and the push still takes the chunk into its carry. - Keep one buffer. Give
pushwhatever room you have. It stops when the next row would not fit.
dp_spectrogram_rows_for(s, n) is exact, not an estimate: it counts the
carry already held plus n samples.
A short buffer never loses input¶
The rule is the ring framer's own: a sample is taken unless it would
complete a row out has no room for. So a push into a full buffer stops
at a whole row and dp_spectrogram_consumed(s) says where to resume. Offer
the rest again after you have drained the rows. Input that completes no row
is always taken, even with no room at all.
What is held between calls is the carry: fewer than nfft samples once a
push returns. dp_spectrogram_pending(s) counts the samples pushed that no
row has covered yet.
Ending a stream¶
The last few samples of a stream usually complete no row.
dp_spectrogram_flush(s, row, nfft) writes the one row they are owed,
zero-padded. It is on the hop grid: it starts at the next row start
k·hop, not at the first uncovered sample, so it is exactly the row a
one-shot run over the zero-padded stream would have made. It writes a row if
and only if a sample is uncovered, returns nfft or 0 floats, and restarts
the stream, so a second flush writes nothing. A row buffer that is not exactly
nfft long is refused with -1 (ValueError in Python) before anything
moves, so the owed frame stays owed for a retry.
flush is explicit by design: closing a source does not imply it.
Checkpoint and resume¶
The state is the stream position, the carry and the configuration the rows
depend on. dp_spectrogram_get_state and dp_spectrogram_set_state move it
as a standard state blob, which is version 2. Restore it into a spectrogram
created with the same configuration, and the next push continues the stream
bit for bit, even mid-frame.
A blob from a different configuration is refused on restore, and the object
is unchanged: a different mode, hop, nfft or window, or a different Kaiser
beta. A Hann or rectangular object does not refuse a different beta,
because beta shapes only the Kaiser window. Refusing a checkpoint from another mode
is the owner's ruling on #2102. See Checkpoint & Resume.
What it is not¶
- Not an averager. Fold rows with
AccTraceif you want a video average or a max-hold. - Not a detector. It stops at the row.
- Not a transport. It never learns where its samples came from.
The design, its goals and what it costs are on
the design page: dB rows run about 36 MSa/s on
one core at nfft 1024 and hop 256, three quarters of it the dB
conversion that power rows skip. What power rows cost is not yet measured
on its own (#2094).
The carry is the
ring buffer's framed face; every function's
contract, with an example, is the
C API.