Spectral analysis and baseband/passband conversion for JavaScript, in the browser and in Node.js.
- Welch PSD and spectrograms, numerically matched to
scipy.signal - Streaming, allocation-free versions of both, for live displays, Web Workers and audio callbacks
- Upconversion and downconversion between complex baseband and real passband, ported from SignalAnalysis.jl
- Accepts plain arrays and any numeric typed array (
Float32Array,Float64Array,Int16Array, ...) - Pure ES modules with no runtime dependencies
Install from GitHub with any package manager:
pnpm add github:subnero1/subnerodsp.js#v1.2.0
# or
npm install github:subnero1/subnerodsp.js#v1.2.0Everything is exported from the package root:
import { welch, spectrogram, WelchStream, SpectrogramStream, upconvert, downconvert } from 'subnerodsp';Requires an ES module environment: Node.js 18 or later, or any modern browser, with or without a bundler. Browsers can also load it directly from a CDN:
<script type="module">
import { welch } from 'https://cdn.jsdelivr.net/gh/subnero1/subnerodsp.js@v1.2.0/src/dsp.js';
</script>TypeScript declarations are included in src/dsp.d.ts, along with the option types (SpectrogramOptions, WelchStreamOptions, ...).
import { welch, spectrogram } from 'subnerodsp';
// 2 seconds of a 100 Hz tone sampled at 1 kHz
const fs = 1000;
const signal = Float32Array.from({ length: 2 * fs }, (_, i) => Math.sin(2 * Math.PI * 100 * i / fs));
// Power spectral density
const { frequencies, psd } = welch(signal, { fs, nperseg: 512 });
const peak = psd.indexOf(Math.max(...psd));
console.log(`Peak at ${frequencies[peak].toFixed(1)} Hz`); // Peak at 99.6 Hz
// Spectrogram in dB, indexed as spec[frequency][time]
const { times, spectrogram: spec } = spectrogram(signal, { fs, nperseg: 256, mode: 'db' });| Export | Use it for | Output |
|---|---|---|
welch(x, options) |
PSD of a whole signal | { frequencies, psd } as plain arrays |
spectrogram(x, options) |
Time-frequency view of a whole signal | { frequencies, times, spectrogram }, with spectrogram[f][t] |
new WelchStream(options) |
Live PSD of the most recent samples in a buffer | Writes numBins values into your array |
new SpectrogramStream(options) |
Live spectrogram, one column per chunk | Writes numBins values into your array, returns the column time |
upconvert(x, options) |
Complex baseband to real passband | Float64Array |
downconvert(x, options) |
Real passband to complex baseband | Float64Array, interleaved complex |
Every option, default and property is documented in the API reference. Sampling rates must be finite and positive. Overlap must be a nonnegative integer smaller than nperseg. Custom windows must have finite samples and positive, finite energy; spectrum scaling and spectrogram dB mode also require a finite, nonzero window sum.
| Option | Default | Meaning |
|---|---|---|
fs |
1.0 |
Sampling rate. Frequencies and times come back in matching units (Hz and seconds if fs is in Hz). |
nperseg |
256 (1024 for WelchStream) |
Segment length. Must be a power of 2 and no longer than the signal. |
noverlap |
nperseg/2 (Welch), nperseg/8 (spectrogram) |
Samples shared by neighbouring segments. |
nfft |
nperseg |
FFT length. A power of 2, at least nperseg. Larger values zero-pad for a smoother-looking spectrum. |
window |
'hann' |
'hann', or your own array of length nperseg. |
detrend |
'constant' |
'constant' removes each segment's mean. Any other value, such as false, disables detrending. |
scaling |
'density' |
'density' gives V²/Hz, 'spectrum' gives V². Applies to mode: 'psd'. |
mode |
'psd' ('db' for the streams) |
'psd', 'magnitude' or 'db'. Not available on welch(). |
The segment length trades frequency resolution against time resolution and variance:
- Bin spacing is
fs / nfft. Raisingnfftabovenperseginterpolates the spectrum but does not separate closer tones. - Frequency resolution is roughly
2 * fs / npersegwith the Hann window. To tell apart two tonesΔfapart, usenperseg ≥ 2 * fs / Δf. - Spectrogram time step is
(nperseg - noverlap) / fsseconds per column. - Welch variance drops as more segments are averaged. For a fixed signal length, a shorter
nperseggives a smoother but coarser PSD.
For example, at fs = 48000 with nperseg = 1024, bins are 46.9 Hz apart, and a spectrogram with noverlap = 512 produces a column every 10.7 ms.
WelchStream and SpectrogramStream take the same options as welch() and spectrogram(). You construct them once, then call process() repeatedly. They allocate nothing after construction and write into an array you own, so they suit render loops and audio callbacks.
Live spectrogram (waterfall). Feed exactly stream.hop new samples per call and get one column back:
import { SpectrogramStream } from 'subnerodsp';
const stream = new SpectrogramStream({ fs: 48000, nperseg: 1024, noverlap: 512 }); // dB by default
const column = new Float32Array(stream.numBins);
function onSamples(chunk) { // chunk.length === stream.hop (512 here)
const t = stream.process(chunk, column); // centre time of this column, in seconds
drawColumn(t, column); // column[i] is the level at stream.frequencies[i]
}The first column is computed against zero-filled history, so output starts immediately. Time zero is the first real input sample. Column times are window centres, so the first time is (nperseg / 2 - noverlap) / fs and can be negative when overlap exceeds half the window. Call stream.reset() to start a new recording and restart this time origin.
Live PSD. Keep the latest samples in a buffer and estimate from its tail:
import { WelchStream } from 'subnerodsp';
const stream = new WelchStream({ fs: 48000, nperseg: 1024, segments: 8 }); // dB by default
const out = new Float32Array(stream.numBins);
// buffer must hold at least stream.needed samples; the most recent ones are used
stream.process(buffer, out);WelchStream averages segments half-overlapped segments, so it needs stream.needed = nperseg + (segments - 1) * nperseg / 2 samples and throws if given fewer. Both streams take an optional nonnegative integer offset argument to process(), to write into one row of a larger array. The destination must be a plain array, Float32Array, or Float64Array with enough space. Invalid destinations or offsets throw before processing.
mode |
Value per bin |
|---|---|
'psd' |
Power, scaled by scaling (V²/Hz or V²) |
'magnitude' |
Amplitude spectral density, ` |
'db' in spectrogram / SpectrogramStream |
Amplitude spectrum in dB, 20·log10(2·abs(X) / abs(Σw) + dbEps). A full-scale sine reads close to 0 dB. |
'db' in WelchStream |
Power spectral density in dB, 10·log10(psd + dbEps) |
Here X is the FFT of the windowed segment and w is the window. dbEps sets the floor that silent bins settle at. It defaults to 1e-12 for spectrograms and 1e-20 for WelchStream.
Baseband signals are complex and stored interleaved: [I0, Q0, I1, Q1, ...]. Passband signals are real.
import { upconvert, downconvert } from 'subnerodsp';
const baseband = [1, 0, 0, 1, -1, 0]; // 3 complex samples: 1, j, -1
const fs = 8000; // baseband sample rate
const sps = 4; // passband samples per baseband sample
const passband = upconvert(baseband, { sps, fc: 12000, fs }); // sampled at sps * fs = 32 kHz
const recovered = downconvert(passband, { sps, fc: 12000, fs: sps * fs }); // back to 8 kHz basebandIn both functions fs is the rate of the input signal. Downconversion computes the Hilbert transform at the original signal length, including lengths that are not powers of two. It assumes the input block repeats periodically, so discontinuities between the end and beginning can still cause boundary effects. With sps > 1 both apply a root raised cosine filter (β = 0.25). upconvert adds 11 baseband samples of filter transient on each side, so its output has (x.length / 2 + 22) * sps samples.
npersegandnfftmust be powers of 2. Other values throw.- The only built-in window is Hann. Pass an array for others, such as Hamming or Blackman.
- Detrending removes the mean or nothing. There is no linear detrend.
- Spectra are one-sided (DC to Nyquist), because inputs are assumed real.
upconvert/downconvertuse a fixed root raised cosine pulse (β = 0.25) and integerspsonly.
The test suite compares outputs against reference data generated by the libraries this one follows:
welchandspectrogramagainst SciPy (scipy.signal), typically agreeing to 1e-12 relative error.upconvertanddownconvertagainst SignalAnalysis.jl, to 1e-9 against a double-precision run of the same algorithm. SignalAnalysis.jl itself keeps carrier phase inFloat32, so agreement with its raw output is about 1e-5.
See tests/README.md for details and for how to regenerate the reference data.
- Bundle fft.js v4.0.4 as an ES module in
src/fft.js, removing the only runtime dependency. The package now loads natively in browsers and Web Workers without a bundler. FFT results are unchanged.
- Ship TypeScript declarations for the full public API, exported through the
typescondition of the package root.
- Reject invalid segment lengths, overlap, sampling rates, custom windows, and streaming output destinations. Negated windows now produce correct spectrogram dB values.
- Measure streaming column times from the first real sample. Times shift by
-noverlap / fsrelative to v1.0.0, and early columns can have negative times. - Compute downconversion at the original signal length, correcting quadrature errors for lengths that are not powers of two. Arbitrary lengths use more temporary memory; periodic boundary effects still apply.
All 75 tests pass, including the stored SciPy and SignalAnalysis.jl reference checks and independent direct-DFT comparisons for arbitrary lengths.
pnpm install
pnpm test # all tests
pnpm test:unit # unit tests only
pnpm test:scipy # SciPy validation
pnpm test:julia # SignalAnalysis.jl validation
pnpm demo # run examples/dsp_demo.jsWhen changing the public API, update the declarations in src/dsp.d.ts to match.
src/fft.js is fft.js by Fedor Indutny, MIT licensed. Its license notice is in the file header.