From 9c265e8d0c14fea92d548ca38cb560c88c3877e4 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Wed, 22 Jul 2026 12:41:27 -0700 Subject: [PATCH 01/24] First attempt at adding spectrogram-js code to seisplot.js --- src/index.mts | 1 + src/index_node.mts | 2 + src/spectrogram.mts | 875 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 878 insertions(+) create mode 100644 src/spectrogram.mts diff --git a/src/index.mts b/src/index.mts index d9c5f04f..bca0f685 100644 --- a/src/index.mts +++ b/src/index.mts @@ -56,6 +56,7 @@ export * as seismographconfig from "./seismographconfig.mjs"; export * as seismographconfigeditor from "./seismographconfigeditor.mjs"; export * as sorting from "./sorting.mjs"; export * as spelement from "./spelement.mjs"; +export * as spectrogram from "./spectrogram.mjs"; export * as stationxml from "./stationxml.mjs"; export * as syngine from "./syngine.mjs"; export * as taper from "./taper.mjs"; diff --git a/src/index_node.mts b/src/index_node.mts index e5ea6d29..ed44aef1 100644 --- a/src/index_node.mts +++ b/src/index_node.mts @@ -89,6 +89,7 @@ const seismographmarker = null; const seismographutil = null; const seismographconfigeditor = null; const spectraplot = null; +const spectrogram = null; const spelement = null; const transition = null; // leaflet cannot run in node as needs window @@ -146,6 +147,7 @@ export { seismographconfigeditor, sorting, spelement, + spectrogram, spectraplot, stationxml, syngine, diff --git a/src/spectrogram.mts b/src/spectrogram.mts new file mode 100644 index 00000000..a4a9572c --- /dev/null +++ b/src/spectrogram.mts @@ -0,0 +1,875 @@ +import { DateTime } from "luxon"; +import { SeismogramDisplayData } from "./seismogram.mjs"; +import { Seismograph } from "./seismograph.mjs"; +import { fftForward } from "./fft.mjs"; +import { SeismographConfig } from "./seismographconfig.mjs"; +import { clearCanvas } from "./seismographutil.mjs"; + +export type WindowFunctionType = + | "hann" + | "hamming" + | "blackman" + | "rectangular"; +const SPECTROGRAM_ELEMENT = "sp-spectrogram"; + +export class SpectrogramConfig extends SeismographConfig { + // Number of time samples in each FFT frame + fftSize: number = 2048; + // Number of samples to overlap (e.g., 512). + overlap: number = 1594; + // Minimum time window for each spectrogram slice in seconds - ideally, resulting chunk times will be near this value + minChunkTime: number = 0; + // Type of window function to apply + windowType: WindowFunctionType = "hann"; + // Frequency range for the spectrogram display in Hz - cannot exceed Nyquist frequency (sampleRate / 2) + freqMin: number = 0; + freqMax: number = 15; + // Lower and upper bounds for spectrogram color scaling in decibels. + // FFT power values below minDb are shown as the darkest color, + // and values above maxDb are shown as the brightest color. + minDb: number = 30; + maxDb: number = 150; + // Color map for spectrogram display + spectrogramColorMap: ColorMapName = "jet"; + + constructor() { + super(); + } +} + +export class Spectrogram extends Seismograph { + spectrogramConfig: SpectrogramConfig; + + constructor(seisData?: SeismogramDisplayData | SeismogramDisplayData[], seisConfig?: SpectrogramConfig) { + super(seisData, seisConfig); + this.spectrogramConfig = seisConfig || new SpectrogramConfig(); + } + + override drawSeismograms() { + if (!this.isVisible()) { + // no need to draw if we are not visible + return; + } + const canvas = this.canvas?.node(); + if (!canvas) + return; + clearCanvas(canvas); + + const fullSeisDataUnformatted: number[] = []; + let dataSampleRate; + this._seisDataList.forEach((sdd, i) => { + const xScale = this.timeScaleForSeisDisplayData(sdd, true); + // const yScale = this.ampScaleForSeisDisplayData(sdd); + const s = xScale.domain().start?.valueOf(); + const e = xScale.domain().end?.valueOf(); + if (s == null || e == null || s === e) { + return; + } + + const seismogram = sdd.seismogram; + if (!seismogram) { + return; + } + + dataSampleRate = seismogram.sampleRate; + fullSeisDataUnformatted.push(...seismogram.y); + }); + + if (dataSampleRate == null) + return; + + const fullSeisData = new Float32Array(fullSeisDataUnformatted); + const durationSec = fullSeisData.length / dataSampleRate; + + // TODO: Do we use durationSec here instead of canvasWidth? durationSec seems to give a much more accurate representation + // of the time window, although it slows down the rendering quite a bit + const spectrogram = new SpectrogramWeb( + this.spectrogramConfig, + dataSampleRate, + canvas.width, + ); + spectrogram.setData(fullSeisData); + + const duration = spectrogram.getDuration(); + const windowSize = durationSec; + + const end = Math.max(0.001, duration); + const start = end - windowSize; + + spectrogram.render({ + canvas: canvas, + width: canvas.width, + height: canvas.height, + timeRange: [start, end], + freqRange: [this.spectrogramConfig.freqMin, this.spectrogramConfig.freqMax], + }).catch(() => { + return; + }); + } +} +customElements.define(SPECTROGRAM_ELEMENT, Spectrogram); + +export interface RenderOptions { + canvas: HTMLCanvasElement; + width: number; + height: number; + timeRange: [number, number]; // [startTime, endTime] + freqRange: [number, number]; // [minFreq, maxFreq] +} + +class SpectrogramWeb { + private model: SpectrogramModel; + private renderer: CanvasRenderer; + + constructor( + config: SpectrogramConfig, + sampleRate: number, + windowSize: number, + ) { + this.model = new SpectrogramModel(config, sampleRate); + this.renderer = new CanvasRenderer(this.model, windowSize); + } + + setData(data: Float32Array) { + this.model.setData(data); + this.renderer.clearCache(); + } + + destroy() { + this.renderer.dispose(); + } + + async render(options: RenderOptions) { + // Default freq range to [0, Nyquist] if not provided + const nyquist = this.model.sampleRate / 2; + const freqRange: [number, number] = [0, nyquist]; + if (this.model.config.freqMin !== undefined) + freqRange[0] = this.model.config.freqMin; + if (this.model.config.freqMax !== undefined) + freqRange[1] = this.model.config.freqMax; + + if (freqRange[0] < 0 || freqRange[1] > nyquist) { + // Frequency range is out of bounds. Adjusting to valid range + freqRange[0] = Math.max(freqRange[0], 0); + freqRange[1] = Math.min(freqRange[1], nyquist); + } + + await this.renderer.render(options, freqRange); + } + + updateConfig(config: Partial) { + this.model.updateConfig(config); + if ( + config.fftSize || + config.overlap || + config.minChunkTime || + config.windowType || + config.freqMin || + config.freqMax || + config.minDb || + config.maxDb || + config.spectrogramColorMap || + config.margin?.left || + config.margin?.top || + config.margin?.right || + config.margin?.bottom + ) { + this.renderer.clearCache(); + } + } + + getDuration(): number { + return this.model.getDuration(); + } +} + +export class SpectrogramModel { + config: SpectrogramConfig; + sampleRate: number; + data: Float32Array | null = null; + + showRealTimeScale: boolean = false; + startTime: number = 0; + + constructor(config: SpectrogramConfig, sampleRate: number) { + this.config = config; + this.sampleRate = sampleRate; + } + + setData(data: Float32Array) { + // Taken from branch where isTimestamped is false because we don't do that here + this.data = data; + this.startTime = 0; + } + + updateConfig(newConfig: Partial) { + Object.assign(this.config, newConfig); + } + + getDuration(): number { + return this.data ? this.data.length / this.sampleRate : 0; + } +} + +export class CanvasRenderer { + private model: SpectrogramModel; + private processor: ChunkProcessor; + private windowSize: number; + + private chunks: Map = new Map(); + private offscreenHelpers: Map = new Map(); + + constructor(model: SpectrogramModel, windowSize: number) { + this.model = model; + this.windowSize = windowSize; + this.processor = new ChunkProcessor( + model.config, + this.model.sampleRate, + windowSize, + ); + } + + clearCache() { + this.chunks.clear(); + this.offscreenHelpers.clear(); + } + + dispose() { + this.clearCache(); + } + + private setupHiDPICanvas(canvas: HTMLCanvasElement) { + const ctx = canvas.getContext("2d", { alpha: true })!; + + return ctx; + } + + private calibrateCanvas(canvas: HTMLCanvasElement) { + return this.setupHiDPICanvas(canvas); + } + + async render(options: RenderOptions, freqRange: [number, number]) { + const { canvas, timeRange } = options; + const ctx = this.calibrateCanvas(canvas); + if (!ctx) { + return; + } + + const width = canvas.width; + const height = canvas.height; + const [tStart, tEnd] = timeRange; + const [fMin, fMax] = freqRange; + + const plotX = this.model.config.margin.left; + const plotY = this.model.config.margin.top; + const plotW = + width - this.model.config.margin.left - this.model.config.margin.right; + const plotH = + height - this.model.config.margin.bottom - this.model.config.margin.top; + + const colorMap = new ColorMap(this.model.config.spectrogramColorMap); + + ctx.clearRect(0, 0, width, height); + + if (!this.model.data) { + return; + } + + const sampleRate = this.model.sampleRate; + const config = this.model.config; + const hopSize = Math.max(1, this.windowSize - config.overlap); + + const targetSamples = this.model.config.minChunkTime * sampleRate; + const hopsPerChunk = Math.ceil(targetSamples / hopSize); + const chunkSamples = hopsPerChunk * hopSize; + + const viewStartIdx = Math.floor(Math.max(0, tStart * sampleRate)); + const viewEndIdx = Math.floor( + Math.min(this.model.data.length, tEnd * sampleRate), + ); + if (viewEndIdx <= viewStartIdx) { + return; + } + + const startChunkId = Math.floor(viewStartIdx / chunkSamples); + const endChunkId = Math.floor(viewEndIdx / chunkSamples); + + for (let i = startChunkId; i <= endChunkId; i++) { + const chunkStart = i * chunkSamples; + const chunkEnd = Math.min((i + 1) * chunkSamples, this.model.data.length); + const chunkId = `chunk_${i}`; + + let chunk = this.chunks.get(chunkId); + + if (!chunk) { + const newChunk = new DataChunk( + chunkId, + chunkStart, + chunkEnd, + sampleRate, + ); + this.chunks.set(chunkId, newChunk); + + const imgData = this.processor.process( + this.model.data, + chunkStart, + chunkEnd, + config, + (val: number) => colorMap.getRGB(val), + ); + + const bmp = await createImageBitmap(imgData); + newChunk.image = bmp; + + chunk = newChunk; + } + + if (chunk.image && ctx) { + ctx.save(); + ctx.beginPath(); + ctx.rect(plotX, plotY, plotW, plotH); + ctx.clip(); + + this.drawChunk( + ctx, + chunk, + tStart, + tEnd, + fMin, + fMax, + plotX, + plotY, + plotW, + plotH, + ); + + ctx.restore(); + } + } + } + + private drawChunk( + ctx: CanvasRenderingContext2D, + chunk: DataChunk, + viewTStart: number, + viewTEnd: number, + fMin: number, // Hz + fMax: number, // Hz + plotX: number, + plotY: number, + plotW: number, + plotH: number, + ) { + if (!chunk.image) { + return; + } + + const viewDuration = viewTEnd - viewTStart; + const sampleRate = this.model.sampleRate; + const nyquist = sampleRate / 2; + + const chunkStartTime = chunk.startIndex / sampleRate; + const chunkEndTime = chunk.endIndex / sampleRate; + + const x1 = plotX + ((chunkStartTime - viewTStart) / viewDuration) * plotW; + const x2 = plotX + ((chunkEndTime - viewTStart) / viewDuration) * plotW; + + if (x2 <= plotX || x1 >= plotX + plotW) { + return; + } + + const dx = Math.max(plotX, x1); + const dw = Math.min(plotX + plotW, x2) - dx; + + const texW = chunk.image.width; + const sx = ((dx - x1) / (x2 - x1)) * texW; + const sw = (dw / (x2 - x1)) * texW; + + const safeFMax = Math.min(fMax, nyquist); + const safeFMin = Math.max(fMin, 0); + + const texH = chunk.image.height; + const sy_top = (1 - safeFMax / nyquist) * texH; + const sy_bottom = (1 - safeFMin / nyquist) * texH; + const sy_h = sy_bottom - sy_top; + + if (sy_h > 0) { + const dy = plotY; + const dh = plotH; + + ctx.drawImage(chunk.image, sx, sy_top, sw, sy_h, dx, dy, dw, dh); + } + } +} + +export class DataChunk { + public id: string; + public startTime: number; + public endTime: number; + public startIndex: number; + public endIndex: number; + + public image: ImageBitmap | null = null; + public isProcessing: boolean = false; + + constructor( + id: string, + startIdx: number, + endIdx: number, + sampleRate: number, + ) { + this.id = id; + this.startIndex = startIdx; + this.endIndex = endIdx; + this.startTime = startIdx / sampleRate; + this.endTime = endIdx / sampleRate; + } +} + +export class ChunkProcessor { + private windowBuffer: Float32Array; + private inputBuf: Float32Array; + private fftSize: number; + private sampleRate: number; + + constructor( + config: SpectrogramConfig, + sampleRate: number, + windowSize: number, + ) { + this.fftSize = config.fftSize; + this.windowBuffer = createWindow(windowSize, config.windowType); + this.inputBuf = new Float32Array(this.fftSize); + this.sampleRate = sampleRate; + } + + process( + data: Float32Array, + startIdx: number, + endIdx: number, + config: SpectrogramConfig, + colormapToRgb: (normalizedVal: number) => [number, number, number], + ): ImageData { + const { minDb, maxDb, overlap } = config; + const windowSize = this.windowBuffer.length; + const hopSize = Math.max(1, windowSize - overlap); + + const numHops = Math.ceil((endIdx - startIdx) / hopSize); + const width = numHops; + const height = (this.fftSize >> 1) + 1; + + if (width <= 0) { + return new ImageData(1, 1); + } + + const imgData = new ImageData(width, height); + const pixels = imgData.data; + const inputBuf = this.inputBuf; + const windowBuf = this.windowBuffer; + + let dcSum = 0; + let validCount = 0; + for (let i = 0; i < windowSize; i++) { + const idx = startIdx + i; + if (idx >= 0 && idx < data.length) { + dcSum += data[idx]!; + validCount++; + } + } + + for (let x = 0; x < width; x++) { + const signalStart = startIdx + x * hopSize; + const mean = validCount > 0 ? dcSum / validCount : 0; + + const end = Math.min(windowSize, data.length - signalStart); + let i = 0; + for (; i < end; i++) { + if ( + signalStart + i < data.length && + signalStart + i >= 0 && + i >= 0 && + i < windowSize + ) { + inputBuf[i] = (data[signalStart + i]! - mean) * windowBuf[i]!; + } + } + for (; i < this.fftSize; i++) { + inputBuf[i] = 0; + } + + const fft = new FFTExecutor(this.fftSize); + const mags = fft.compute(inputBuf, this.sampleRate, minDb, maxDb); + + for (let y = 0; y < height; y++) { + if (y < 0 || y >= mags.length) { + // Index is out of bounds for magnitude array + break; + } + const val = mags[y]; + const rgb = colormapToRgb(val!); + const row = height - 1 - y; + const idx = (row * width + x) * 4; + pixels[idx] = rgb[0]; + pixels[idx + 1] = rgb[1]; + pixels[idx + 2] = rgb[2]; + pixels[idx + 3] = 255; + } + + if (x + 1 < width) { + for (let k = 0; k < hopSize; k++) { + const outIdx = signalStart + k; + if (outIdx >= 0 && outIdx < data.length) { + dcSum -= data[outIdx]!; + validCount--; + } + } + + const nextStart = signalStart + windowSize; + for (let k = 0; k < hopSize; k++) { + const inIdx = nextStart + k; + if (inIdx >= 0 && inIdx < data.length) { + dcSum += data[inIdx]!; + validCount++; + } + } + } + } + + return imgData; + } +} + +class FFTExecutor { + private readonly EPS = 1e-20; + private readonly INV_LN10 = 1 / Math.LN10; + + private readonly fftSize: number; + + private readonly complexIn: Float32Array; + private readonly spectrum: Float32Array; + + constructor(fftSize: number) { + if ((fftSize & (fftSize - 1)) !== 0) { + throw new Error("FFT size must be power of two"); + } + + this.fftSize = fftSize; + + this.complexIn = new Float32Array(fftSize * 2); + this.spectrum = new Float32Array(fftSize / 2 + 1); + } + + size(): number { + return this.fftSize; + } + + compute( + input: Float32Array, + sampleRate: number, + minDb: number, + maxDb: number, + ): Float32Array { + const N = this.fftSize; + const cin = this.complexIn; + + for (let i = 0; i < N; i++) { + const j = i << 1; + cin[j] = input[i]!; + cin[j + 1] = 0; + } + + // We can use 0 for the startTime because seisplot doesn't use it for fftForward + const inputDisplayData = + SeismogramDisplayData.fromContiguousData( + cin, + sampleRate, + DateTime.fromMillis(0), + ); + const out: Float32Array = fftForward(inputDisplayData).packedFreq; + const spec = this.spectrum; + + const n = spec.length; + const invRange = 1 / (maxDb - minDb); + const eps = this.EPS; + const invLn10 = this.INV_LN10; + + for (let i = 0; i < n; i++) { + const realComp = out[i]; + let p = 0; + // Check if valid FFT output values + if (realComp !== undefined) { + p = realComp * realComp + eps; + } + + const v = (10 * Math.log(p) * invLn10 - minDb) * invRange; + spec[i] = v < 0 ? 0 : v > 1 ? 1 : v; + } + + return spec; + } +} + +const createWindow = (size: number, type: WindowFunctionType): Float32Array => { + const window = new Float32Array(size); + + if (type === "rectangular") { + return window.fill(1); + } + + const TWO_PI = 2 * Math.PI; + const denom = size - 1; + + switch (type) { + case "hann": + for (let i = 0; i < size; i++) { + window[i] = 0.5 * (1 - Math.cos((TWO_PI * i) / denom)); + } + break; + case "hamming": + for (let i = 0; i < size; i++) { + window[i] = 0.54 - 0.46 * Math.cos((TWO_PI * i) / denom); + } + break; + case "blackman": + for (let i = 0; i < size; i++) { + const angle = (TWO_PI * i) / denom; + window[i] = 0.42 - 0.5 * Math.cos(angle) + 0.08 * Math.cos(2 * angle); + } + break; + } + + return window; +}; + +export type ColorMapName = + | "viridis" + | "inferno" + | "grayscale" + | "jet" + | "hot" + | "cool" + | "spring" + | "summer" + | "autumn" + | "winter" + | "bone"; + +export type RGB = [number, number, number]; + +function interpolateColorMap(t: number, map: number[][]): RGB { + if (t <= 0) { + return map[0] as RGB; + } + if (t >= 1) { + return map[map.length - 1] as RGB; + } + + const step = 1 / (map.length - 1); + const idx = (t / step) | 0; + const localT = (t - idx * step) / step; + + const c1 = map[idx]; + const c2 = map[idx + 1]; + + if (!c1 || c1.length < 3 || !c2 || c2.length < 3) { + return [0, 0, 0]; + } + + return [ + (c1[0]! + (c2[0]! - c1[0]!) * localT) | 0, + (c1[1]! + (c2[1]! - c1[1]!) * localT) | 0, + (c1[2]! + (c2[2]! - c1[2]!) * localT) | 0, + ]; +} + +const VIRIDIS_MAP = [ + [68, 1, 84], + [59, 82, 139], + [33, 145, 140], + [94, 201, 98], + [253, 231, 37], +]; +const INFERNO_MAP = [ + [0, 0, 4], + [87, 16, 110], + [187, 55, 84], + [249, 142, 9], + [252, 255, 164], +]; + +function viridis(t: number): RGB { + return interpolateColorMap(t, VIRIDIS_MAP); +} + +function inferno(t: number): RGB { + return interpolateColorMap(t, INFERNO_MAP); +} + +function grayscale(t: number): RGB { + const v = Math.floor(t * 255); + return [v, v, v]; +} + +function jet(t: number): RGB { + // Jet: Blue -> Cyan -> Yellow -> Orange -> Red + // t: 0..1 + const v = Math.max(0, Math.min(1, t)); + // R: 0 at 0.35, 1 at 0.66 + // G: 0 at 0.12, 1 at 0.37, 1 at 0.64, 0 at 0.89 + // B: 1 at 0.11, 0 at 0.34 + + // Simple 4-segment interpolation + const r = Math.min(4 * v - 1.5, -4 * v + 4.5); + const g = Math.min(4 * v - 0.5, -4 * v + 3.5); + const b = Math.min(4 * v + 0.5, -4 * v + 2.5); + + return [ + Math.floor(Math.max(0, Math.min(1, r)) * 255), + Math.floor(Math.max(0, Math.min(1, g)) * 255), + Math.floor(Math.max(0, Math.min(1, b)) * 255), + ]; +} + +function hot(t: number): RGB { + // Black -> Red -> Yellow -> White + // R: 0->1 linear (0-0.33) + // G: 0 (0-0.33) -> 1 (0.66-1) + // B: 0 (0-0.66) -> 1 (1) + + // Easier with keypoints: + // 0.0: 0,0,0 + // 0.33: 255,0,0 + // 0.66: 255,255,0 + // 1.0: 255,255,255 + + let r, + g = 0, + b = 0; + + if (t < 0.33) { + r = t / 0.33; + } else if (t < 0.66) { + r = 1; + g = (t - 0.33) / 0.33; + } else { + r = 1; + g = 1; + b = (t - 0.66) / 0.34; + } + + return [Math.floor(r * 255), Math.floor(g * 255), Math.floor(b * 255)]; +} + +function cool(t: number): RGB { + // Cyan -> Magenta + // R: 0 -> 1 + // G: 1 -> 0 + // B: 1 + const r = t; + const g = 1 - t; + const b = 1; + return [Math.floor(r * 255), Math.floor(g * 255), Math.floor(b * 255)]; +} + +function spring(t: number): RGB { + // Magenta -> Yellow + // R: 1 + // G: t + // B: 1 - t + return [255, Math.floor(t * 255), Math.floor((1 - t) * 255)]; +} + +function summer(t: number): RGB { + // Green -> Yellow + // R: t + // G: 0.5 + 0.5*t + // B: 0.4 + // Standard matplotlib 'summer' is simpler + // 0.0: (0.0, 0.5, 0.4) + // 1.0: (1.0, 1.0, 0.4) + return [ + Math.floor(t * 255), + Math.floor((0.5 + 0.5 * t) * 255), + Math.floor(0.4 * 255), + ]; +} + +function autumn(t: number): RGB { + // Red -> Orange -> Yellow + // R: 1 + // G: t + // B: 0 + return [255, Math.floor(t * 255), 0]; +} + +function winter(t: number): RGB { + // Blue -> Green + // 0.0: (0, 0, 1) + // 1.0: (0, 1, 0.5) + // R: 0 + // G: t + // B: 1.0 - 0.5*t + return [0, Math.floor(t * 255), Math.floor((1.0 - 0.5 * t) * 255)]; +} + +function bone(t: number): RGB { + const r = t; + const sin = 0.1 * Math.sin(t * Math.PI * 2); + const g = t < 0.5 ? t + sin : t; + const b = t < 0.75 ? t + sin : t; + + return [ + Math.floor(Math.min(1, r) * 255), + Math.floor(Math.min(1, g) * 255), + Math.floor(Math.min(1, b) * 255), + ]; +} + +const COLOR_MAP_FNS: Record RGB> = { + viridis, + inferno, + grayscale, + jet, + hot, + cool, + spring, + summer, + autumn, + winter, + bone, +}; + +export class ColorMap { + private type: ColorMapName; + private lut: Uint8Array; // [R, G, B, R, G, B...] for 0..255 + + constructor(type: ColorMapName = "jet") { + this.type = type; + this.lut = new Uint8Array(256 * 3); + this.generateLut(); + } + + private generateLut() { + const fn = COLOR_MAP_FNS[this.type]; + for (let i = 0; i < 256; i++) { + const rgb = fn(i / 255); + const j = i * 3; + this.lut[j] = rgb[0]; + this.lut[j + 1] = rgb[1]; + this.lut[j + 2] = rgb[2]; + } + } + + getRGB(t: number): RGB { + const idx = (t <= 0 ? 0 : t >= 1 ? 255 : (t * 255) | 0) * 3; + if (idx < 0 || idx + 2 >= this.lut.length) { + return [0, 0, 0]; + } + return [this.lut[idx]!, this.lut[idx + 1]!, this.lut[idx + 2]!]; + } + + setMap(type: ColorMapName) { + this.type = type; + this.generateLut(); + } +} From d127bea39b4a9787d72d2eb4b62bb3cb00917318 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Mon, 27 Jul 2026 16:19:43 -0700 Subject: [PATCH 02/24] Exchange overlap config for overlap percentage --- src/spectrogram.mts | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index a4a9572c..0322d292 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -4,6 +4,7 @@ import { Seismograph } from "./seismograph.mjs"; import { fftForward } from "./fft.mjs"; import { SeismographConfig } from "./seismographconfig.mjs"; import { clearCanvas } from "./seismographutil.mjs"; +import { util } from "./index_node.mjs"; export type WindowFunctionType = | "hann" @@ -15,10 +16,10 @@ const SPECTROGRAM_ELEMENT = "sp-spectrogram"; export class SpectrogramConfig extends SeismographConfig { // Number of time samples in each FFT frame fftSize: number = 2048; - // Number of samples to overlap (e.g., 512). - overlap: number = 1594; + // How much to overlap each FFT frame (as a fraction of the window size) + overlapPerc: number = 0.95; // Minimum time window for each spectrogram slice in seconds - ideally, resulting chunk times will be near this value - minChunkTime: number = 0; + minChunkTime: number = 10; // Type of window function to apply windowType: WindowFunctionType = "hann"; // Frequency range for the spectrogram display in Hz - cannot exceed Nyquist frequency (sampleRate / 2) @@ -57,7 +58,7 @@ export class Spectrogram extends Seismograph { const fullSeisDataUnformatted: number[] = []; let dataSampleRate; - this._seisDataList.forEach((sdd, i) => { + this._seisDataList.forEach((sdd) => { const xScale = this.timeScaleForSeisDisplayData(sdd, true); // const yScale = this.ampScaleForSeisDisplayData(sdd); const s = xScale.domain().start?.valueOf(); @@ -102,7 +103,8 @@ export class Spectrogram extends Seismograph { height: canvas.height, timeRange: [start, end], freqRange: [this.spectrogramConfig.freqMin, this.spectrogramConfig.freqMax], - }).catch(() => { + }).catch((err) => { + util.warn(`Error rendering spectrogram: ${err.message}`); return; }); } @@ -161,7 +163,7 @@ class SpectrogramWeb { this.model.updateConfig(config); if ( config.fftSize || - config.overlap || + config.overlapPerc || config.minChunkTime || config.windowType || config.freqMin || @@ -260,13 +262,6 @@ export class CanvasRenderer { const [tStart, tEnd] = timeRange; const [fMin, fMax] = freqRange; - const plotX = this.model.config.margin.left; - const plotY = this.model.config.margin.top; - const plotW = - width - this.model.config.margin.left - this.model.config.margin.right; - const plotH = - height - this.model.config.margin.bottom - this.model.config.margin.top; - const colorMap = new ColorMap(this.model.config.spectrogramColorMap); ctx.clearRect(0, 0, width, height); @@ -277,7 +272,8 @@ export class CanvasRenderer { const sampleRate = this.model.sampleRate; const config = this.model.config; - const hopSize = Math.max(1, this.windowSize - config.overlap); + const overlap = Math.floor(config.overlapPerc * this.windowSize); + const hopSize = Math.max(1, this.windowSize - overlap); const targetSamples = this.model.config.minChunkTime * sampleRate; const hopsPerChunk = Math.ceil(targetSamples / hopSize); @@ -327,7 +323,7 @@ export class CanvasRenderer { if (chunk.image && ctx) { ctx.save(); ctx.beginPath(); - ctx.rect(plotX, plotY, plotW, plotH); + ctx.rect(0, 0, width, height); ctx.clip(); this.drawChunk( @@ -337,10 +333,10 @@ export class CanvasRenderer { tEnd, fMin, fMax, - plotX, - plotY, - plotW, - plotH, + 0, + 0, + width, + height, ); ctx.restore(); @@ -450,8 +446,9 @@ export class ChunkProcessor { config: SpectrogramConfig, colormapToRgb: (normalizedVal: number) => [number, number, number], ): ImageData { - const { minDb, maxDb, overlap } = config; + const { minDb, maxDb, overlapPerc } = config; const windowSize = this.windowBuffer.length; + const overlap = Math.floor(overlapPerc * windowSize); const hopSize = Math.max(1, windowSize - overlap); const numHops = Math.ceil((endIdx - startIdx) / hopSize); From 6bebca0c3911b80738fc99e9ee0406e75097fc59 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Wed, 29 Jul 2026 15:42:37 -0700 Subject: [PATCH 03/24] First attempt at rendering spectrogram according to time scale --- src/spectrogram.mts | 90 +++++++++++++++++++++------------------------ 1 file changed, 41 insertions(+), 49 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 0322d292..4be6b84b 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -56,14 +56,13 @@ export class Spectrogram extends Seismograph { return; clearCanvas(canvas); - const fullSeisDataUnformatted: number[] = []; let dataSampleRate; this._seisDataList.forEach((sdd) => { const xScale = this.timeScaleForSeisDisplayData(sdd, true); // const yScale = this.ampScaleForSeisDisplayData(sdd); - const s = xScale.domain().start?.valueOf(); - const e = xScale.domain().end?.valueOf(); - if (s == null || e == null || s === e) { + const domainStart = xScale.domain().start?.valueOf(); + const domainEnd = xScale.domain().end?.valueOf(); + if (domainStart == null || domainEnd == null || domainStart === domainEnd) { return; } @@ -73,39 +72,44 @@ export class Spectrogram extends Seismograph { } dataSampleRate = seismogram.sampleRate; - fullSeisDataUnformatted.push(...seismogram.y); - }); - if (dataSampleRate == null) - return; + if (dataSampleRate == null) + return; - const fullSeisData = new Float32Array(fullSeisDataUnformatted); - const durationSec = fullSeisData.length / dataSampleRate; + const fullSeisData = new Float32Array(seismogram.y); + const durationSec = fullSeisData.length / dataSampleRate; - // TODO: Do we use durationSec here instead of canvasWidth? durationSec seems to give a much more accurate representation - // of the time window, although it slows down the rendering quite a bit - const spectrogram = new SpectrogramWeb( - this.spectrogramConfig, - dataSampleRate, - canvas.width, - ); - spectrogram.setData(fullSeisData); - - const duration = spectrogram.getDuration(); - const windowSize = durationSec; - - const end = Math.max(0.001, duration); - const start = end - windowSize; - - spectrogram.render({ - canvas: canvas, - width: canvas.width, - height: canvas.height, - timeRange: [start, end], - freqRange: [this.spectrogramConfig.freqMin, this.spectrogramConfig.freqMax], - }).catch((err) => { - util.warn(`Error rendering spectrogram: ${err.message}`); - return; + // TODO: Do we use durationSec here instead of canvasWidth? durationSec seems to give a much more accurate representation + // of the time window, although it slows down the rendering quite a bit + const spectrogram = new SpectrogramWeb( + this.spectrogramConfig, + dataSampleRate, + durationSec, + ); + + let seismogramStartSec = seismogram.startTime.valueOf() / 1000 - domainStart; + let seismogramEndSec = domainEnd - seismogram.endTime.valueOf() / 1000; + let startSamplesToTrim = 0; + let endSamplesToTrim = 0; + + if (seismogramStartSec < 0) { + startSamplesToTrim = Math.ceil(-seismogramStartSec * dataSampleRate); + seismogramStartSec = 0; + } + if (seismogramEndSec > durationSec) { + endSamplesToTrim = Math.ceil((seismogramEndSec - durationSec) * dataSampleRate); + seismogramEndSec = durationSec; + } + + spectrogram.setData(fullSeisData.slice(startSamplesToTrim, fullSeisData.length - endSamplesToTrim)); + + spectrogram.render( + canvas, + [seismogramStartSec, seismogramEndSec], + ).catch((err) => { + util.warn(`Error rendering spectrogram: ${err.message}`); + return; + }); }); } } @@ -113,10 +117,7 @@ customElements.define(SPECTROGRAM_ELEMENT, Spectrogram); export interface RenderOptions { canvas: HTMLCanvasElement; - width: number; - height: number; timeRange: [number, number]; // [startTime, endTime] - freqRange: [number, number]; // [minFreq, maxFreq] } class SpectrogramWeb { @@ -141,7 +142,7 @@ class SpectrogramWeb { this.renderer.dispose(); } - async render(options: RenderOptions) { + async render(canvas: HTMLCanvasElement, timeRange: [number, number]) { // Default freq range to [0, Nyquist] if not provided const nyquist = this.model.sampleRate / 2; const freqRange: [number, number] = [0, nyquist]; @@ -156,7 +157,7 @@ class SpectrogramWeb { freqRange[1] = Math.min(freqRange[1], nyquist); } - await this.renderer.render(options, freqRange); + await this.renderer.render(canvas, timeRange, freqRange); } updateConfig(config: Partial) { @@ -179,10 +180,6 @@ class SpectrogramWeb { this.renderer.clearCache(); } } - - getDuration(): number { - return this.model.getDuration(); - } } export class SpectrogramModel { @@ -207,10 +204,6 @@ export class SpectrogramModel { updateConfig(newConfig: Partial) { Object.assign(this.config, newConfig); } - - getDuration(): number { - return this.data ? this.data.length / this.sampleRate : 0; - } } export class CanvasRenderer { @@ -250,8 +243,7 @@ export class CanvasRenderer { return this.setupHiDPICanvas(canvas); } - async render(options: RenderOptions, freqRange: [number, number]) { - const { canvas, timeRange } = options; + async render(canvas: HTMLCanvasElement, timeRange: [number, number], freqRange: [number, number]) { const ctx = this.calibrateCanvas(canvas); if (!ctx) { return; From e548c5bab330d61186cef6c513e1909d41bc93f6 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Wed, 29 Jul 2026 18:33:05 -0700 Subject: [PATCH 04/24] Better names in drawSeismograms and small bug fix --- src/spectrogram.mts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 4be6b84b..64c9bf01 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -84,29 +84,27 @@ export class Spectrogram extends Seismograph { const spectrogram = new SpectrogramWeb( this.spectrogramConfig, dataSampleRate, - durationSec, + canvas.width, ); - let seismogramStartSec = seismogram.startTime.valueOf() / 1000 - domainStart; - let seismogramEndSec = domainEnd - seismogram.endTime.valueOf() / 1000; + const seismogramStartSec = (seismogram.startTime.valueOf() - domainStart) / 1000; + const seismogramEndSec = (seismogram.endTime.valueOf() - domainStart) / 1000; let startSamplesToTrim = 0; let endSamplesToTrim = 0; if (seismogramStartSec < 0) { startSamplesToTrim = Math.ceil(-seismogramStartSec * dataSampleRate); - seismogramStartSec = 0; } if (seismogramEndSec > durationSec) { endSamplesToTrim = Math.ceil((seismogramEndSec - durationSec) * dataSampleRate); - seismogramEndSec = durationSec; } - spectrogram.setData(fullSeisData.slice(startSamplesToTrim, fullSeisData.length - endSamplesToTrim)); + const visibleData = fullSeisData.slice(startSamplesToTrim, fullSeisData.length - endSamplesToTrim); + const visibleDurationSec = visibleData.length / dataSampleRate; - spectrogram.render( - canvas, - [seismogramStartSec, seismogramEndSec], - ).catch((err) => { + spectrogram.setData(visibleData); + + spectrogram.render(canvas, [0, visibleDurationSec]).catch((err) => { util.warn(`Error rendering spectrogram: ${err.message}`); return; }); From d0a43e8f2d6539ea74778ba085ea3df8a0948b17 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 31 Jul 2026 11:36:30 -0700 Subject: [PATCH 05/24] Document drawChunk --- src/spectrogram.mts | 65 +++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 64c9bf01..c98f7124 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -323,8 +323,6 @@ export class CanvasRenderer { tEnd, fMin, fMax, - 0, - 0, width, height, ); @@ -334,15 +332,24 @@ export class CanvasRenderer { } } + /** + * Draws a chunk of spectrogram within the view range + * @param ctx 2D rendering context to draw on + * @param chunk DataChunk instance to draw + * @param viewStartTime Start time of the view range in seconds + * @param viewEndTime End time of the view range in seconds + * @param fMin Minimum frequency to display + * @param fMax Maximum frequency to display + * @param plotW Width of the plot area + * @param plotH Height of the plot area + */ private drawChunk( ctx: CanvasRenderingContext2D, chunk: DataChunk, - viewTStart: number, - viewTEnd: number, + viewStartTime: number, + viewEndTime: number, fMin: number, // Hz fMax: number, // Hz - plotX: number, - plotY: number, plotW: number, plotH: number, ) { @@ -350,40 +357,52 @@ export class CanvasRenderer { return; } - const viewDuration = viewTEnd - viewTStart; + const viewDuration = viewEndTime - viewStartTime; const sampleRate = this.model.sampleRate; const nyquist = sampleRate / 2; const chunkStartTime = chunk.startIndex / sampleRate; const chunkEndTime = chunk.endIndex / sampleRate; - const x1 = plotX + ((chunkStartTime - viewTStart) / viewDuration) * plotW; - const x2 = plotX + ((chunkEndTime - viewTStart) / viewDuration) * plotW; + // Calculate the x-coordinates for the chunk within the plot area + const x1 = ((chunkStartTime - viewStartTime) / viewDuration) * plotW; + const x2 = ((chunkEndTime - viewStartTime) / viewDuration) * plotW; - if (x2 <= plotX || x1 >= plotX + plotW) { + if (x2 <= 0 || x1 >= plotW) { return; } - const dx = Math.max(plotX, x1); - const dw = Math.min(plotX + plotW, x2) - dx; + // Constrain the x-coordinates to the plot area + const chunkX = Math.max(0, x1); + const chunkW = Math.min(plotW, x2) - chunkX; + // If the x-coordinates don't fill the entire chunk area, draw only the portion that does const texW = chunk.image.width; - const sx = ((dx - x1) / (x2 - x1)) * texW; - const sw = (dw / (x2 - x1)) * texW; + const drawStartX = ((chunkX - x1) / (x2 - x1)) * texW; + const drawWidth = (chunkW / (x2 - x1)) * texW; + // Constrain the y-coordinates to be within the safe frequency range const safeFMax = Math.min(fMax, nyquist); const safeFMin = Math.max(fMin, 0); + // If the y-coordinates don't fill the entire frequency range, draw only the portion that does const texH = chunk.image.height; - const sy_top = (1 - safeFMax / nyquist) * texH; - const sy_bottom = (1 - safeFMin / nyquist) * texH; - const sy_h = sy_bottom - sy_top; - - if (sy_h > 0) { - const dy = plotY; - const dh = plotH; - - ctx.drawImage(chunk.image, sx, sy_top, sw, sy_h, dx, dy, dw, dh); + const drawStartY = (1 - safeFMax / nyquist) * texH; + const drawEndY = (1 - safeFMin / nyquist) * texH; + const drawHeight = drawEndY - drawStartY; + + if (drawHeight > 0) { + ctx.drawImage( + chunk.image, + drawStartX, + drawStartY, + drawWidth, + drawHeight, + chunkX, + 0, + chunkW, + plotH + ); } } } From bb1a7b44d380e87fac9e825d7faa5b36a976da17 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 31 Jul 2026 12:45:13 -0700 Subject: [PATCH 06/24] Document and clean up render, remove SpectrogramWeb for simplicity --- src/spectrogram.mts | 204 +++++++++++++++++--------------------------- 1 file changed, 78 insertions(+), 126 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index c98f7124..41636337 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -78,13 +78,20 @@ export class Spectrogram extends Seismograph { const fullSeisData = new Float32Array(seismogram.y); const durationSec = fullSeisData.length / dataSampleRate; + const startTime = seismogram.startTime.valueOf() / 1000; - // TODO: Do we use durationSec here instead of canvasWidth? durationSec seems to give a much more accurate representation - // of the time window, although it slows down the rendering quite a bit - const spectrogram = new SpectrogramWeb( + // TODO: In order to optimize rendering, the canvas renderer should be initialized in the constructor somehow so that we can + // use the canvas renderer's cache appropriately + const spectrogram = new SpectrogramModel( this.spectrogramConfig, dataSampleRate, - canvas.width, + startTime, + ); + // TODO: Do we use durationSec here instead of canvasWidth? durationSec seems to give a much more accurate representation + // of the time window, although it slows down the rendering quite a bit. Wait, does it mean the window size of the fft? + const canvasRenderer = new CanvasRenderer( + spectrogram, + canvas.width, // this.spectrogramConfig.fftSize? ); const seismogramStartSec = (seismogram.startTime.valueOf() - domainStart) / 1000; @@ -100,11 +107,8 @@ export class Spectrogram extends Seismograph { } const visibleData = fullSeisData.slice(startSamplesToTrim, fullSeisData.length - endSamplesToTrim); - const visibleDurationSec = visibleData.length / dataSampleRate; - spectrogram.setData(visibleData); - - spectrogram.render(canvas, [0, visibleDurationSec]).catch((err) => { + canvasRenderer.render(canvas).catch((err) => { util.warn(`Error rendering spectrogram: ${err.message}`); return; }); @@ -118,85 +122,24 @@ export interface RenderOptions { timeRange: [number, number]; // [startTime, endTime] } -class SpectrogramWeb { - private model: SpectrogramModel; - private renderer: CanvasRenderer; - - constructor( - config: SpectrogramConfig, - sampleRate: number, - windowSize: number, - ) { - this.model = new SpectrogramModel(config, sampleRate); - this.renderer = new CanvasRenderer(this.model, windowSize); - } - - setData(data: Float32Array) { - this.model.setData(data); - this.renderer.clearCache(); - } - - destroy() { - this.renderer.dispose(); - } - - async render(canvas: HTMLCanvasElement, timeRange: [number, number]) { - // Default freq range to [0, Nyquist] if not provided - const nyquist = this.model.sampleRate / 2; - const freqRange: [number, number] = [0, nyquist]; - if (this.model.config.freqMin !== undefined) - freqRange[0] = this.model.config.freqMin; - if (this.model.config.freqMax !== undefined) - freqRange[1] = this.model.config.freqMax; - - if (freqRange[0] < 0 || freqRange[1] > nyquist) { - // Frequency range is out of bounds. Adjusting to valid range - freqRange[0] = Math.max(freqRange[0], 0); - freqRange[1] = Math.min(freqRange[1], nyquist); - } - - await this.renderer.render(canvas, timeRange, freqRange); - } - - updateConfig(config: Partial) { - this.model.updateConfig(config); - if ( - config.fftSize || - config.overlapPerc || - config.minChunkTime || - config.windowType || - config.freqMin || - config.freqMax || - config.minDb || - config.maxDb || - config.spectrogramColorMap || - config.margin?.left || - config.margin?.top || - config.margin?.right || - config.margin?.bottom - ) { - this.renderer.clearCache(); - } - } -} - -export class SpectrogramModel { +class SpectrogramModel { config: SpectrogramConfig; sampleRate: number; data: Float32Array | null = null; showRealTimeScale: boolean = false; + // Start time of the spectrogram in seconds from epoch startTime: number = 0; - constructor(config: SpectrogramConfig, sampleRate: number) { + constructor(config: SpectrogramConfig, sampleRate: number, startTime: number) { this.config = config; this.sampleRate = sampleRate; + this.startTime = startTime; } setData(data: Float32Array) { // Taken from branch where isTimestamped is false because we don't do that here this.data = data; - this.startTime = 0; } updateConfig(newConfig: Partial) { @@ -204,21 +147,21 @@ export class SpectrogramModel { } } -export class CanvasRenderer { +class CanvasRenderer { private model: SpectrogramModel; private processor: ChunkProcessor; - private windowSize: number; + private canvasSize: number; private chunks: Map = new Map(); private offscreenHelpers: Map = new Map(); - constructor(model: SpectrogramModel, windowSize: number) { + constructor(model: SpectrogramModel, canvasSize: number) { this.model = model; - this.windowSize = windowSize; + this.canvasSize = canvasSize; this.processor = new ChunkProcessor( - model.config, + this.model.config, this.model.sampleRate, - windowSize, + canvasSize, ); } @@ -231,62 +174,61 @@ export class CanvasRenderer { this.clearCache(); } - private setupHiDPICanvas(canvas: HTMLCanvasElement) { + /** + * Renders the SpectrogramModel on the given canvas at the specified time and frequency ranges + * @param canvas The canvas element on which to render the spectrogram + * @param viewTimeRange The time range to display in seconds from epoch + * @returns A promise resolving when rendering is complete + */ + async render(canvas: HTMLCanvasElement, viewTimeRange: [number, number]) { const ctx = canvas.getContext("2d", { alpha: true })!; - - return ctx; - } - - private calibrateCanvas(canvas: HTMLCanvasElement) { - return this.setupHiDPICanvas(canvas); - } - - async render(canvas: HTMLCanvasElement, timeRange: [number, number], freqRange: [number, number]) { - const ctx = this.calibrateCanvas(canvas); - if (!ctx) { + if (!ctx || !this.model.data || viewTimeRange[0] >= viewTimeRange[1]) { return; } - const width = canvas.width; - const height = canvas.height; - const [tStart, tEnd] = timeRange; - const [fMin, fMax] = freqRange; + // Get time and frequency range from model + const dataStartTime = this.model.startTime; + const dataEndTime = dataStartTime + this.model.data?.length / this.model.sampleRate; + const fMin = this.model.config.freqMin; + const fMax = this.model.config.freqMax; const colorMap = new ColorMap(this.model.config.spectrogramColorMap); - ctx.clearRect(0, 0, width, height); - - if (!this.model.data) { - return; - } + ctx.clearRect(0, 0, canvas.width, canvas.height); + // Calculates how big each chunk should be according to the desired time range and overlap const sampleRate = this.model.sampleRate; - const config = this.model.config; - const overlap = Math.floor(config.overlapPerc * this.windowSize); - const hopSize = Math.max(1, this.windowSize - overlap); + const overlap = Math.floor(this.model.config.overlapPerc * this.canvasSize); + const hopSize = Math.max(1, this.canvasSize - overlap); + // Calculates the number of samples to include in each chunk based on the desired minimum chunk + // time. A minimum chunk time is often used for performance reasons const targetSamples = this.model.config.minChunkTime * sampleRate; const hopsPerChunk = Math.ceil(targetSamples / hopSize); const chunkSamples = hopsPerChunk * hopSize; - const viewStartIdx = Math.floor(Math.max(0, tStart * sampleRate)); - const viewEndIdx = Math.floor( - Math.min(this.model.data.length, tEnd * sampleRate), - ); - if (viewEndIdx <= viewStartIdx) { - return; - } + // Calculates where the data starts and ends relative to the start of the view range + const dataRelStartIdx = (dataStartTime - viewTimeRange[0]) * sampleRate; + const dataRelEndIdx = (dataEndTime - viewTimeRange[0]) * sampleRate; - const startChunkId = Math.floor(viewStartIdx / chunkSamples); - const endChunkId = Math.floor(viewEndIdx / chunkSamples); + // Only render the chunks that have data + const startChunkId = Math.floor(dataRelStartIdx / chunkSamples); + const endChunkId = Math.floor(dataRelEndIdx / chunkSamples); for (let i = startChunkId; i <= endChunkId; i++) { - const chunkStart = i * chunkSamples; - const chunkEnd = Math.min((i + 1) * chunkSamples, this.model.data.length); - const chunkId = `chunk_${i}`; + let chunkStart = i * chunkSamples; + let chunkEnd = (i + 1) * chunkSamples; + let chunkId = `chunk_${i}`; + + // If the chunk is only partially filled with data, it is a special chunk and should not be stored in + // the cache in the same way as a regular chunk + if (chunkStart < dataRelEndIdx || chunkEnd > dataRelEndIdx) { + chunkStart = Math.max(chunkStart, dataRelStartIdx); + chunkEnd = Math.min(chunkEnd, dataRelEndIdx); + chunkId = `chunk_${chunkStart}_${chunkEnd}`; + } let chunk = this.chunks.get(chunkId); - if (!chunk) { const newChunk = new DataChunk( chunkId, @@ -296,14 +238,16 @@ export class CanvasRenderer { ); this.chunks.set(chunkId, newChunk); + // Convert the processed data to an image const imgData = this.processor.process( this.model.data, chunkStart, chunkEnd, - config, + this.model.config, (val: number) => colorMap.getRGB(val), ); + // Store the created image into the chunk so it can be reused const bmp = await createImageBitmap(imgData); newChunk.image = bmp; @@ -311,20 +255,19 @@ export class CanvasRenderer { } if (chunk.image && ctx) { + // Fills any unused part of the canvas with background ctx.save(); ctx.beginPath(); - ctx.rect(0, 0, width, height); + ctx.rect(0, 0, canvas.width, canvas.height); ctx.clip(); this.drawChunk( ctx, chunk, - tStart, - tEnd, + dataStartTime, + dataEndTime, fMin, - fMax, - width, - height, + fMax ); ctx.restore(); @@ -350,8 +293,6 @@ export class CanvasRenderer { viewEndTime: number, fMin: number, // Hz fMax: number, // Hz - plotW: number, - plotH: number, ) { if (!chunk.image) { return; @@ -365,6 +306,7 @@ export class CanvasRenderer { const chunkEndTime = chunk.endIndex / sampleRate; // Calculate the x-coordinates for the chunk within the plot area + const plotW = ctx.canvas.width; const x1 = ((chunkStartTime - viewStartTime) / viewDuration) * plotW; const x2 = ((chunkEndTime - viewStartTime) / viewDuration) * plotW; @@ -401,7 +343,7 @@ export class CanvasRenderer { chunkX, 0, chunkW, - plotH + ctx.canvas.height ); } } @@ -448,6 +390,16 @@ export class ChunkProcessor { this.sampleRate = sampleRate; } + /** + * Processes the seismic data to create a spectrogram using fftForward, the given colormap, and + * index offsets for the input data. + * @param data The seismic data to process + * @param startIdx The starting index of the data to start the chunk at + * @param endIdx The ending index of the data to end the chunk at + * @param config The spectrogram configuration + * @param colormapToRgb The function to convert normalized values to RGB colors + * @returns The processed spectrogram image data + */ process( data: Float32Array, startIdx: number, From b212a4086fc9ca9840522d822267478322e8c532 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 31 Jul 2026 12:57:48 -0700 Subject: [PATCH 07/24] Fix drawSeismograms to work with changes, plus some function cleanup --- src/spectrogram.mts | 88 +++++++++++++++++++-------------------------- 1 file changed, 36 insertions(+), 52 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 41636337..6c22aa41 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -56,37 +56,49 @@ export class Spectrogram extends Seismograph { return; clearCanvas(canvas); - let dataSampleRate; this._seisDataList.forEach((sdd) => { + // Get the time range for the view of the spectrogram and validate const xScale = this.timeScaleForSeisDisplayData(sdd, true); - // const yScale = this.ampScaleForSeisDisplayData(sdd); const domainStart = xScale.domain().start?.valueOf(); const domainEnd = xScale.domain().end?.valueOf(); if (domainStart == null || domainEnd == null || domainStart === domainEnd) { return; } + // Get seismogram and sampleRate, and validate const seismogram = sdd.seismogram; - if (!seismogram) { + if (!seismogram) return; - } - - dataSampleRate = seismogram.sampleRate; - + const dataSampleRate = seismogram.sampleRate; if (dataSampleRate == null) return; + // Get the full seismogram data and calculate how much to trim const fullSeisData = new Float32Array(seismogram.y); - const durationSec = fullSeisData.length / dataSampleRate; - const startTime = seismogram.startTime.valueOf() / 1000; + let seismogramStartSec = seismogram.startTime.valueOf() / 1000; + const seismogramEndSec = seismogram.endTime.valueOf() / 1000; + let startSamplesToTrim = 0; + let endSamplesToTrim = 0; - // TODO: In order to optimize rendering, the canvas renderer should be initialized in the constructor somehow so that we can - // use the canvas renderer's cache appropriately + if (seismogramStartSec < domainStart) { + startSamplesToTrim = Math.ceil((domainStart - seismogramStartSec) * dataSampleRate); + seismogramStartSec = domainStart; + } + if (seismogramEndSec > domainEnd) { + endSamplesToTrim = Math.ceil((seismogramEndSec - domainEnd) * dataSampleRate); + } + + // Initialize the spectrogram model with our trimmed start time const spectrogram = new SpectrogramModel( this.spectrogramConfig, dataSampleRate, - startTime, + seismogramStartSec, ); + const visibleData = fullSeisData.slice(startSamplesToTrim, fullSeisData.length - endSamplesToTrim); + spectrogram.setData(visibleData); + + // TODO: In order to optimize rendering, the canvas renderer should be initialized in the constructor somehow so that we can + // use the canvas renderer's cache appropriately // TODO: Do we use durationSec here instead of canvasWidth? durationSec seems to give a much more accurate representation // of the time window, although it slows down the rendering quite a bit. Wait, does it mean the window size of the fft? const canvasRenderer = new CanvasRenderer( @@ -94,21 +106,7 @@ export class Spectrogram extends Seismograph { canvas.width, // this.spectrogramConfig.fftSize? ); - const seismogramStartSec = (seismogram.startTime.valueOf() - domainStart) / 1000; - const seismogramEndSec = (seismogram.endTime.valueOf() - domainStart) / 1000; - let startSamplesToTrim = 0; - let endSamplesToTrim = 0; - - if (seismogramStartSec < 0) { - startSamplesToTrim = Math.ceil(-seismogramStartSec * dataSampleRate); - } - if (seismogramEndSec > durationSec) { - endSamplesToTrim = Math.ceil((seismogramEndSec - durationSec) * dataSampleRate); - } - - const visibleData = fullSeisData.slice(startSamplesToTrim, fullSeisData.length - endSamplesToTrim); - spectrogram.setData(visibleData); - canvasRenderer.render(canvas).catch((err) => { + canvasRenderer.render(canvas, domainStart, domainEnd).catch((err) => { util.warn(`Error rendering spectrogram: ${err.message}`); return; }); @@ -117,11 +115,6 @@ export class Spectrogram extends Seismograph { } customElements.define(SPECTROGRAM_ELEMENT, Spectrogram); -export interface RenderOptions { - canvas: HTMLCanvasElement; - timeRange: [number, number]; // [startTime, endTime] -} - class SpectrogramModel { config: SpectrogramConfig; sampleRate: number; @@ -138,13 +131,8 @@ class SpectrogramModel { } setData(data: Float32Array) { - // Taken from branch where isTimestamped is false because we don't do that here this.data = data; } - - updateConfig(newConfig: Partial) { - Object.assign(this.config, newConfig); - } } class CanvasRenderer { @@ -152,8 +140,7 @@ class CanvasRenderer { private processor: ChunkProcessor; private canvasSize: number; - private chunks: Map = new Map(); - private offscreenHelpers: Map = new Map(); + private chunksCache: Map = new Map(); constructor(model: SpectrogramModel, canvasSize: number) { this.model = model; @@ -165,24 +152,21 @@ class CanvasRenderer { ); } + // TODO: Where can we expose this or use it for efficiency? clearCache() { - this.chunks.clear(); - this.offscreenHelpers.clear(); - } - - dispose() { - this.clearCache(); + this.chunksCache.clear(); } /** * Renders the SpectrogramModel on the given canvas at the specified time and frequency ranges * @param canvas The canvas element on which to render the spectrogram - * @param viewTimeRange The time range to display in seconds from epoch + * @param viewStartTime The start time of the view range in seconds from epoch + * @param viewEndTime The end time of the view range in seconds from epoch * @returns A promise resolving when rendering is complete */ - async render(canvas: HTMLCanvasElement, viewTimeRange: [number, number]) { + async render(canvas: HTMLCanvasElement, viewStartTime: number, viewEndTime: number) { const ctx = canvas.getContext("2d", { alpha: true })!; - if (!ctx || !this.model.data || viewTimeRange[0] >= viewTimeRange[1]) { + if (!ctx || !this.model.data || viewStartTime >= viewEndTime) { return; } @@ -208,8 +192,8 @@ class CanvasRenderer { const chunkSamples = hopsPerChunk * hopSize; // Calculates where the data starts and ends relative to the start of the view range - const dataRelStartIdx = (dataStartTime - viewTimeRange[0]) * sampleRate; - const dataRelEndIdx = (dataEndTime - viewTimeRange[0]) * sampleRate; + const dataRelStartIdx = (dataStartTime - viewStartTime) * sampleRate; + const dataRelEndIdx = (dataEndTime - viewStartTime) * sampleRate; // Only render the chunks that have data const startChunkId = Math.floor(dataRelStartIdx / chunkSamples); @@ -228,7 +212,7 @@ class CanvasRenderer { chunkId = `chunk_${chunkStart}_${chunkEnd}`; } - let chunk = this.chunks.get(chunkId); + let chunk = this.chunksCache.get(chunkId); if (!chunk) { const newChunk = new DataChunk( chunkId, @@ -236,7 +220,7 @@ class CanvasRenderer { chunkEnd, sampleRate, ); - this.chunks.set(chunkId, newChunk); + this.chunksCache.set(chunkId, newChunk); // Convert the processed data to an image const imgData = this.processor.process( From 4439ee893b83c94120c0ba4bfcd8ad11cc84be8a Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 31 Jul 2026 17:46:05 -0700 Subject: [PATCH 08/24] Fix millisecond-second mismatch, and mixup in CanvasRender render chunk id check --- src/spectrogram.mts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 6c22aa41..98d798f7 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -74,18 +74,20 @@ export class Spectrogram extends Seismograph { return; // Get the full seismogram data and calculate how much to trim + const viewStartTime = domainStart / 1000; + const viewEndTime = domainEnd / 1000; const fullSeisData = new Float32Array(seismogram.y); let seismogramStartSec = seismogram.startTime.valueOf() / 1000; const seismogramEndSec = seismogram.endTime.valueOf() / 1000; let startSamplesToTrim = 0; let endSamplesToTrim = 0; - if (seismogramStartSec < domainStart) { - startSamplesToTrim = Math.ceil((domainStart - seismogramStartSec) * dataSampleRate); - seismogramStartSec = domainStart; + if (seismogramStartSec < viewStartTime) { + startSamplesToTrim = Math.ceil((viewStartTime - seismogramStartSec) * dataSampleRate); + seismogramStartSec = viewStartTime; } - if (seismogramEndSec > domainEnd) { - endSamplesToTrim = Math.ceil((seismogramEndSec - domainEnd) * dataSampleRate); + if (seismogramEndSec > viewEndTime) { + endSamplesToTrim = Math.ceil((seismogramEndSec - viewEndTime) * dataSampleRate); } // Initialize the spectrogram model with our trimmed start time @@ -106,7 +108,7 @@ export class Spectrogram extends Seismograph { canvas.width, // this.spectrogramConfig.fftSize? ); - canvasRenderer.render(canvas, domainStart, domainEnd).catch((err) => { + canvasRenderer.render(canvas, viewStartTime, viewEndTime).catch((err) => { util.warn(`Error rendering spectrogram: ${err.message}`); return; }); @@ -166,7 +168,7 @@ class CanvasRenderer { */ async render(canvas: HTMLCanvasElement, viewStartTime: number, viewEndTime: number) { const ctx = canvas.getContext("2d", { alpha: true })!; - if (!ctx || !this.model.data || viewStartTime >= viewEndTime) { + if (!ctx || !this.model.data || !this.model.data.length || viewStartTime >= viewEndTime) { return; } @@ -206,7 +208,7 @@ class CanvasRenderer { // If the chunk is only partially filled with data, it is a special chunk and should not be stored in // the cache in the same way as a regular chunk - if (chunkStart < dataRelEndIdx || chunkEnd > dataRelEndIdx) { + if (chunkStart < dataRelStartIdx || chunkEnd > dataRelEndIdx) { chunkStart = Math.max(chunkStart, dataRelStartIdx); chunkEnd = Math.min(chunkEnd, dataRelEndIdx); chunkId = `chunk_${chunkStart}_${chunkEnd}`; @@ -291,8 +293,8 @@ class CanvasRenderer { // Calculate the x-coordinates for the chunk within the plot area const plotW = ctx.canvas.width; - const x1 = ((chunkStartTime - viewStartTime) / viewDuration) * plotW; - const x2 = ((chunkEndTime - viewStartTime) / viewDuration) * plotW; + const x1 = (chunkStartTime / viewDuration) * plotW; + const x2 = (chunkEndTime / viewDuration) * plotW; if (x2 <= 0 || x1 >= plotW) { return; From 437b17bbb54de75e22b4d017100a6852247cb233 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 31 Jul 2026 18:18:04 -0700 Subject: [PATCH 09/24] Fix mismatches between relative and absolute time in different places, stop trimming in drawSeismograms for better math --- src/spectrogram.mts | 44 ++++++++++++++++++-------------------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 98d798f7..07676ca6 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -77,18 +77,7 @@ export class Spectrogram extends Seismograph { const viewStartTime = domainStart / 1000; const viewEndTime = domainEnd / 1000; const fullSeisData = new Float32Array(seismogram.y); - let seismogramStartSec = seismogram.startTime.valueOf() / 1000; - const seismogramEndSec = seismogram.endTime.valueOf() / 1000; - let startSamplesToTrim = 0; - let endSamplesToTrim = 0; - - if (seismogramStartSec < viewStartTime) { - startSamplesToTrim = Math.ceil((viewStartTime - seismogramStartSec) * dataSampleRate); - seismogramStartSec = viewStartTime; - } - if (seismogramEndSec > viewEndTime) { - endSamplesToTrim = Math.ceil((seismogramEndSec - viewEndTime) * dataSampleRate); - } + const seismogramStartSec = seismogram.startTime.valueOf() / 1000; // Initialize the spectrogram model with our trimmed start time const spectrogram = new SpectrogramModel( @@ -96,8 +85,7 @@ export class Spectrogram extends Seismograph { dataSampleRate, seismogramStartSec, ); - const visibleData = fullSeisData.slice(startSamplesToTrim, fullSeisData.length - endSamplesToTrim); - spectrogram.setData(visibleData); + spectrogram.setData(fullSeisData); // TODO: In order to optimize rendering, the canvas renderer should be initialized in the constructor somehow so that we can // use the canvas renderer's cache appropriately @@ -193,13 +181,18 @@ class CanvasRenderer { const hopsPerChunk = Math.ceil(targetSamples / hopSize); const chunkSamples = hopsPerChunk * hopSize; - // Calculates where the data starts and ends relative to the start of the view range - const dataRelStartIdx = (dataStartTime - viewStartTime) * sampleRate; - const dataRelEndIdx = (dataEndTime - viewStartTime) * sampleRate; + // Calculates where the data starts and ends relative to the start of the view range while + // respecting the view boundaries + const overlapStartAbs = Math.max(viewStartTime, dataStartTime); + const overlapEndAbs = Math.min(viewEndTime, dataEndTime); + if (overlapEndAbs <= overlapStartAbs) + return; + const dataRelStartIdx = Math.floor((overlapStartAbs - dataStartTime) * sampleRate); + const dataRelEndIdx = Math.ceil((overlapEndAbs - dataStartTime) * sampleRate); // Only render the chunks that have data const startChunkId = Math.floor(dataRelStartIdx / chunkSamples); - const endChunkId = Math.floor(dataRelEndIdx / chunkSamples); + const endChunkId = Math.floor((dataRelEndIdx - 1) / chunkSamples); for (let i = startChunkId; i <= endChunkId; i++) { let chunkStart = i * chunkSamples; @@ -250,8 +243,8 @@ class CanvasRenderer { this.drawChunk( ctx, chunk, - dataStartTime, - dataEndTime, + viewStartTime, + viewEndTime, fMin, fMax ); @@ -288,17 +281,16 @@ class CanvasRenderer { const sampleRate = this.model.sampleRate; const nyquist = sampleRate / 2; - const chunkStartTime = chunk.startIndex / sampleRate; - const chunkEndTime = chunk.endIndex / sampleRate; + const chunkStartTime = this.model.startTime + chunk.startIndex / sampleRate; + const chunkEndTime = this.model.startTime + chunk.endIndex / sampleRate; // Calculate the x-coordinates for the chunk within the plot area const plotW = ctx.canvas.width; - const x1 = (chunkStartTime / viewDuration) * plotW; - const x2 = (chunkEndTime / viewDuration) * plotW; + const x1 = ((chunkStartTime - viewStartTime) / viewDuration) * plotW; + const x2 = ((chunkEndTime - viewStartTime) / viewDuration) * plotW; - if (x2 <= 0 || x1 >= plotW) { + if (x2 <= 0 || x1 >= plotW) return; - } // Constrain the x-coordinates to the plot area const chunkX = Math.max(0, x1); From 8fde3f69ffa845866f0103f2f256d5b14b510f8f Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Mon, 3 Aug 2026 15:45:48 -0700 Subject: [PATCH 10/24] FINALLY figured out what the windowSize means --- src/spectrogram.mts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 07676ca6..cadde191 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -14,10 +14,12 @@ export type WindowFunctionType = const SPECTROGRAM_ELEMENT = "sp-spectrogram"; export class SpectrogramConfig extends SeismographConfig { - // Number of time samples in each FFT frame - fftSize: number = 2048; + // The number of points used to compute the FFT, determining the number of frequency bins in the spectrogram + fftSize: number = 512; + // The number of samples extracted for each distinct time frame. Must be <= fftSize + windowSize: number = 512; // How much to overlap each FFT frame (as a fraction of the window size) - overlapPerc: number = 0.95; + overlapPerc: number = 0.5; // Minimum time window for each spectrogram slice in seconds - ideally, resulting chunk times will be near this value minChunkTime: number = 10; // Type of window function to apply @@ -89,11 +91,9 @@ export class Spectrogram extends Seismograph { // TODO: In order to optimize rendering, the canvas renderer should be initialized in the constructor somehow so that we can // use the canvas renderer's cache appropriately - // TODO: Do we use durationSec here instead of canvasWidth? durationSec seems to give a much more accurate representation - // of the time window, although it slows down the rendering quite a bit. Wait, does it mean the window size of the fft? const canvasRenderer = new CanvasRenderer( spectrogram, - canvas.width, // this.spectrogramConfig.fftSize? + this.spectrogramConfig.windowSize, ); canvasRenderer.render(canvas, viewStartTime, viewEndTime).catch((err) => { @@ -128,17 +128,17 @@ class SpectrogramModel { class CanvasRenderer { private model: SpectrogramModel; private processor: ChunkProcessor; - private canvasSize: number; + private windowSize: number; private chunksCache: Map = new Map(); constructor(model: SpectrogramModel, canvasSize: number) { this.model = model; - this.canvasSize = canvasSize; + this.windowSize = canvasSize; this.processor = new ChunkProcessor( this.model.config, this.model.sampleRate, - canvasSize, + this.windowSize, ); } @@ -172,8 +172,8 @@ class CanvasRenderer { // Calculates how big each chunk should be according to the desired time range and overlap const sampleRate = this.model.sampleRate; - const overlap = Math.floor(this.model.config.overlapPerc * this.canvasSize); - const hopSize = Math.max(1, this.canvasSize - overlap); + const overlap = Math.floor(this.model.config.overlapPerc * this.windowSize); + const hopSize = Math.max(1, this.windowSize - overlap); // Calculates the number of samples to include in each chunk based on the desired minimum chunk // time. A minimum chunk time is often used for performance reasons From 471a7e65a0dc819f777e9f96922d5d33d52cddb1 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Tue, 4 Aug 2026 10:43:15 -0700 Subject: [PATCH 11/24] Change leftRight axis to be frequency --- src/spectrogram.mts | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index cadde191..cbaee83b 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -2,9 +2,15 @@ import { DateTime } from "luxon"; import { SeismogramDisplayData } from "./seismogram.mjs"; import { Seismograph } from "./seismograph.mjs"; import { fftForward } from "./fft.mjs"; -import { SeismographConfig } from "./seismographconfig.mjs"; +import { SeismographConfig, numberFormatWrapper } from "./seismographconfig.mjs"; import { clearCanvas } from "./seismographutil.mjs"; import { util } from "./index_node.mjs"; +import type { Axis } from "d3-axis"; +import type { NumberValue as d3NumberValue } from "d3-scale"; +import { + axisLeft as d3axisLeft, + axisRight as d3axisRight, +} from "d3-axis"; export type WindowFunctionType = | "hann" @@ -27,6 +33,7 @@ export class SpectrogramConfig extends SeismographConfig { // Frequency range for the spectrogram display in Hz - cannot exceed Nyquist frequency (sampleRate / 2) freqMin: number = 0; freqMax: number = 15; + frequencyFormat: (val: number) => string = (val) => val.toFixed(1); // Lower and upper bounds for spectrogram color scaling in decibels. // FFT power values below minDb are shown as the darkest color, // and values above maxDb are shown as the brightest color. @@ -37,6 +44,8 @@ export class SpectrogramConfig extends SeismographConfig { constructor() { super(); + // Set new defaults for SeismographConfig fields + this.yLabel = "Frequency"; } } @@ -102,6 +111,34 @@ export class Spectrogram extends Seismograph { }); }); } + + override createLeftRightAxis(): Array | null> { + let yAxis = null; + let yAxisRight = null; + const axisScale = this.__initAmpScale() + .domain([this.spectrogramConfig.freqMin, this.spectrogramConfig.freqMax]); + if (this.spectrogramConfig.isYAxis) { + yAxis = d3axisLeft(axisScale).tickFormat( + numberFormatWrapper(this.spectrogramConfig.frequencyFormat), + ); + yAxis.scale(axisScale); + yAxis.ticks(this.spectrogramConfig.yAxisNumTickHint, + this.spectrogramConfig.frequencyFormat); + } + + if (this.spectrogramConfig.isYAxisRight) { + yAxisRight = d3axisRight(axisScale).tickFormat( + numberFormatWrapper(this.spectrogramConfig.frequencyFormat), + ); + yAxisRight.scale(axisScale); + yAxisRight.ticks(this.spectrogramConfig.yAxisNumTickHint, this.spectrogramConfig.frequencyFormat); + } + return [yAxis, yAxisRight]; + } + + override createUnitsLabel() { + return "Hz"; + } } customElements.define(SPECTROGRAM_ELEMENT, Spectrogram); From c9fab7675df861dc17e57ba359f2274d84275913 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Wed, 5 Aug 2026 19:13:54 -0700 Subject: [PATCH 12/24] Use PNSN standard spectrogram settings as defaults --- src/spectrogram.mts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index cbaee83b..7beeb628 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -21,11 +21,11 @@ const SPECTROGRAM_ELEMENT = "sp-spectrogram"; export class SpectrogramConfig extends SeismographConfig { // The number of points used to compute the FFT, determining the number of frequency bins in the spectrogram - fftSize: number = 512; + fftSize: number = 256; // The number of samples extracted for each distinct time frame. Must be <= fftSize - windowSize: number = 512; + windowSize: number = 256; // How much to overlap each FFT frame (as a fraction of the window size) - overlapPerc: number = 0.5; + overlapPerc: number = 0.86; // Minimum time window for each spectrogram slice in seconds - ideally, resulting chunk times will be near this value minChunkTime: number = 10; // Type of window function to apply From f186c1dcd7fd65831c9e37dcad9d819ecb5517cc Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Wed, 5 Aug 2026 20:26:43 -0700 Subject: [PATCH 13/24] Move CanvasRenderer to the constructor to not be reinitialized all the time --- src/spectrogram.mts | 92 ++++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 47 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 7beeb628..6755f90a 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -51,10 +51,15 @@ export class SpectrogramConfig extends SeismographConfig { export class Spectrogram extends Seismograph { spectrogramConfig: SpectrogramConfig; + canvasRenderer: CanvasRenderer; constructor(seisData?: SeismogramDisplayData | SeismogramDisplayData[], seisConfig?: SpectrogramConfig) { super(seisData, seisConfig); this.spectrogramConfig = seisConfig || new SpectrogramConfig(); + this.canvasRenderer = new CanvasRenderer( + this.canvas?.node() as HTMLCanvasElement, + this.spectrogramConfig.windowSize, + ); } override drawSeismograms() { @@ -98,14 +103,7 @@ export class Spectrogram extends Seismograph { ); spectrogram.setData(fullSeisData); - // TODO: In order to optimize rendering, the canvas renderer should be initialized in the constructor somehow so that we can - // use the canvas renderer's cache appropriately - const canvasRenderer = new CanvasRenderer( - spectrogram, - this.spectrogramConfig.windowSize, - ); - - canvasRenderer.render(canvas, viewStartTime, viewEndTime).catch((err) => { + this.canvasRenderer.render(spectrogram, viewStartTime, viewEndTime).catch((err) => { util.warn(`Error rendering spectrogram: ${err.message}`); return; }); @@ -163,20 +161,15 @@ class SpectrogramModel { } class CanvasRenderer { - private model: SpectrogramModel; - private processor: ChunkProcessor; + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D | null = null; private windowSize: number; private chunksCache: Map = new Map(); - constructor(model: SpectrogramModel, canvasSize: number) { - this.model = model; + constructor(canvas: HTMLCanvasElement, canvasSize: number) { + this.canvas = canvas; this.windowSize = canvasSize; - this.processor = new ChunkProcessor( - this.model.config, - this.model.sampleRate, - this.windowSize, - ); } // TODO: Where can we expose this or use it for efficiency? @@ -191,30 +184,35 @@ class CanvasRenderer { * @param viewEndTime The end time of the view range in seconds from epoch * @returns A promise resolving when rendering is complete */ - async render(canvas: HTMLCanvasElement, viewStartTime: number, viewEndTime: number) { - const ctx = canvas.getContext("2d", { alpha: true })!; - if (!ctx || !this.model.data || !this.model.data.length || viewStartTime >= viewEndTime) { + async render(spectrogram: SpectrogramModel, viewStartTime: number, viewEndTime: number) { + this.ctx = this.canvas.getContext("2d", { alpha: true })!; + const processor = new ChunkProcessor( + spectrogram.config, + spectrogram.sampleRate, + this.windowSize, + ); + if (!this.ctx || !spectrogram.data || !spectrogram.data.length || viewStartTime >= viewEndTime) { return; } // Get time and frequency range from model - const dataStartTime = this.model.startTime; - const dataEndTime = dataStartTime + this.model.data?.length / this.model.sampleRate; - const fMin = this.model.config.freqMin; - const fMax = this.model.config.freqMax; + const dataStartTime = spectrogram.startTime; + const dataEndTime = dataStartTime + spectrogram.data?.length / spectrogram.sampleRate; + const fMin = spectrogram.config.freqMin; + const fMax = spectrogram.config.freqMax; - const colorMap = new ColorMap(this.model.config.spectrogramColorMap); + const colorMap = new ColorMap(spectrogram.config.spectrogramColorMap); - ctx.clearRect(0, 0, canvas.width, canvas.height); + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // Calculates how big each chunk should be according to the desired time range and overlap - const sampleRate = this.model.sampleRate; - const overlap = Math.floor(this.model.config.overlapPerc * this.windowSize); + const sampleRate = spectrogram.sampleRate; + const overlap = Math.floor(spectrogram.config.overlapPerc * this.windowSize); const hopSize = Math.max(1, this.windowSize - overlap); // Calculates the number of samples to include in each chunk based on the desired minimum chunk // time. A minimum chunk time is often used for performance reasons - const targetSamples = this.model.config.minChunkTime * sampleRate; + const targetSamples = spectrogram.config.minChunkTime * sampleRate; const hopsPerChunk = Math.ceil(targetSamples / hopSize); const chunkSamples = hopsPerChunk * hopSize; @@ -255,11 +253,11 @@ class CanvasRenderer { this.chunksCache.set(chunkId, newChunk); // Convert the processed data to an image - const imgData = this.processor.process( - this.model.data, + const imgData = processor.process( + spectrogram.data, chunkStart, chunkEnd, - this.model.config, + spectrogram.config, (val: number) => colorMap.getRGB(val), ); @@ -270,15 +268,15 @@ class CanvasRenderer { chunk = newChunk; } - if (chunk.image && ctx) { + if (chunk.image && this.ctx) { // Fills any unused part of the canvas with background - ctx.save(); - ctx.beginPath(); - ctx.rect(0, 0, canvas.width, canvas.height); - ctx.clip(); + this.ctx.save(); + this.ctx.beginPath(); + this.ctx.rect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.clip(); this.drawChunk( - ctx, + spectrogram, chunk, viewStartTime, viewEndTime, @@ -286,7 +284,7 @@ class CanvasRenderer { fMax ); - ctx.restore(); + this.ctx.restore(); } } } @@ -303,7 +301,7 @@ class CanvasRenderer { * @param plotH Height of the plot area */ private drawChunk( - ctx: CanvasRenderingContext2D, + spectrogram: SpectrogramModel, chunk: DataChunk, viewStartTime: number, viewEndTime: number, @@ -315,14 +313,14 @@ class CanvasRenderer { } const viewDuration = viewEndTime - viewStartTime; - const sampleRate = this.model.sampleRate; + const sampleRate = spectrogram.sampleRate; const nyquist = sampleRate / 2; - const chunkStartTime = this.model.startTime + chunk.startIndex / sampleRate; - const chunkEndTime = this.model.startTime + chunk.endIndex / sampleRate; + const chunkStartTime = spectrogram.startTime + chunk.startIndex / sampleRate; + const chunkEndTime = spectrogram.startTime + chunk.endIndex / sampleRate; // Calculate the x-coordinates for the chunk within the plot area - const plotW = ctx.canvas.width; + const plotW = this.canvas.width; const x1 = ((chunkStartTime - viewStartTime) / viewDuration) * plotW; const x2 = ((chunkEndTime - viewStartTime) / viewDuration) * plotW; @@ -348,8 +346,8 @@ class CanvasRenderer { const drawEndY = (1 - safeFMin / nyquist) * texH; const drawHeight = drawEndY - drawStartY; - if (drawHeight > 0) { - ctx.drawImage( + if (this.ctx && drawHeight > 0) { + this.ctx.drawImage( chunk.image, drawStartX, drawStartY, @@ -358,7 +356,7 @@ class CanvasRenderer { chunkX, 0, chunkW, - ctx.canvas.height + this.canvas.height ); } } From e53e578bee82f6edf375a17feff40ab36575be4e Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Wed, 5 Aug 2026 21:34:47 -0700 Subject: [PATCH 14/24] Yay! The cache works! What a performance buff :) Also, add ability to remove units label --- src/spectrogram.mts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 6755f90a..eeb666a5 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -71,6 +71,7 @@ export class Spectrogram extends Seismograph { if (!canvas) return; clearCanvas(canvas); + this.canvasRenderer.setCanvas(canvas); this._seisDataList.forEach((sdd) => { // Get the time range for the view of the spectrogram and validate @@ -135,7 +136,16 @@ export class Spectrogram extends Seismograph { } override createUnitsLabel() { - return "Hz"; + if (this.spectrogramConfig.ySublabelIsUnits) { + return "Hz"; + } + return ""; + } + + override seisDataUpdated() { + // Invalidate the cache when the data is updated, so that new chunks will be generated for the new data + this.canvasRenderer.clearCache(); + super.seisDataUpdated(); } } customElements.define(SPECTROGRAM_ELEMENT, Spectrogram); @@ -172,11 +182,14 @@ class CanvasRenderer { this.windowSize = canvasSize; } - // TODO: Where can we expose this or use it for efficiency? clearCache() { this.chunksCache.clear(); } + setCanvas(canvas: HTMLCanvasElement) { + this.canvas = canvas; + } + /** * Renders the SpectrogramModel on the given canvas at the specified time and frequency ranges * @param canvas The canvas element on which to render the spectrogram From 70e8abc737476b5a11d7935c46f6286511d75eb9 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 7 Aug 2026 11:34:44 -0700 Subject: [PATCH 15/24] Make sure to equally constrain frequency range in axis labels, added some comments --- src/spectrogram.mts | 57 +++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index eeb666a5..970ea068 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -17,6 +17,20 @@ export type WindowFunctionType = | "hamming" | "blackman" | "rectangular"; +export type ColorMapName = + | "viridis" + | "inferno" + | "grayscale" + | "jet" + | "hot" + | "cool" + | "spring" + | "summer" + | "autumn" + | "winter" + | "bone"; +export type RGB = [number, number, number]; + const SPECTROGRAM_ELEMENT = "sp-spectrogram"; export class SpectrogramConfig extends SeismographConfig { @@ -24,19 +38,21 @@ export class SpectrogramConfig extends SeismographConfig { fftSize: number = 256; // The number of samples extracted for each distinct time frame. Must be <= fftSize windowSize: number = 256; - // How much to overlap each FFT frame (as a fraction of the window size) + // How much to overlap each FFT frame (as a fraction of the windowSize). A higher overlap results in smoother spectrograms, but + // increases computation time overlapPerc: number = 0.86; - // Minimum time window for each spectrogram slice in seconds - ideally, resulting chunk times will be near this value + // Minimum time window for each spectrogram slice in seconds - ideally, resulting chunk times will be near this value. A higher + // value results in bigger chunks and better performance, but may result in a less smooth spectrogram minChunkTime: number = 10; // Type of window function to apply windowType: WindowFunctionType = "hann"; - // Frequency range for the spectrogram display in Hz - cannot exceed Nyquist frequency (sampleRate / 2) + // Frequency range for the spectrogram display in Hz - always constrained to not exceed the Nyquist frequency (sampleRate / 2) freqMin: number = 0; freqMax: number = 15; + // Function to format frequency values for display on the y-axis. Default is to format with 1 decimal place frequencyFormat: (val: number) => string = (val) => val.toFixed(1); - // Lower and upper bounds for spectrogram color scaling in decibels. - // FFT power values below minDb are shown as the darkest color, - // and values above maxDb are shown as the brightest color. + // Lower and upper bounds for spectrogram color scaling in decibels. FFT power values below minDb are shown as the darkest + // color, and values above maxDb are shown as the brightest color. minDb: number = 30; maxDb: number = 150; // Color map for spectrogram display @@ -114,8 +130,18 @@ export class Spectrogram extends Seismograph { override createLeftRightAxis(): Array | null> { let yAxis = null; let yAxisRight = null; + + // Constrain the y-coordinates to be within the safe frequency range + const sampleRate = this.seisData.reduce( + (acc, curr) => Math.min(curr.seismogram?.sampleRate || Infinity, acc), + Infinity + ); + const nyquist = sampleRate !== Infinity ? sampleRate / 2 : 0; + const safeFMax = Math.min(this.spectrogramConfig.freqMax, nyquist); + const safeFMin = Math.max(this.spectrogramConfig.freqMin, 0); + const axisScale = this.__initAmpScale() - .domain([this.spectrogramConfig.freqMin, this.spectrogramConfig.freqMax]); + .domain([safeFMin, safeFMax]); if (this.spectrogramConfig.isYAxis) { yAxis = d3axisLeft(axisScale).tickFormat( numberFormatWrapper(this.spectrogramConfig.frequencyFormat), @@ -136,7 +162,7 @@ export class Spectrogram extends Seismograph { } override createUnitsLabel() { - if (this.spectrogramConfig.ySublabelIsUnits) { + if (this.spectrogramConfig && this.spectrogramConfig.ySublabelIsUnits) { return "Hz"; } return ""; @@ -625,21 +651,6 @@ const createWindow = (size: number, type: WindowFunctionType): Float32Array => { return window; }; -export type ColorMapName = - | "viridis" - | "inferno" - | "grayscale" - | "jet" - | "hot" - | "cool" - | "spring" - | "summer" - | "autumn" - | "winter" - | "bone"; - -export type RGB = [number, number, number]; - function interpolateColorMap(t: number, map: number[][]): RGB { if (t <= 0) { return map[0] as RGB; From 3174c207ec1365facf9d6db5779fc778ddd76306 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 7 Aug 2026 11:40:36 -0700 Subject: [PATCH 16/24] Add more comments to drawSeismograms --- src/spectrogram.mts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 970ea068..2edecfd9 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -83,12 +83,14 @@ export class Spectrogram extends Seismograph { // no need to draw if we are not visible return; } + // Clear the canvas before drawing, making sure the canvasRenderer is set to the current canvas const canvas = this.canvas?.node(); if (!canvas) return; clearCanvas(canvas); this.canvasRenderer.setCanvas(canvas); + // Draw a separate SpectrogramModel for each SeismogramDisplayData in the list to adjust for different sample rates and start times this._seisDataList.forEach((sdd) => { // Get the time range for the view of the spectrogram and validate const xScale = this.timeScaleForSeisDisplayData(sdd, true); @@ -106,13 +108,13 @@ export class Spectrogram extends Seismograph { if (dataSampleRate == null) return; - // Get the full seismogram data and calculate how much to trim + // Convert the domain start and end times to seconds from epoch, and get the full seismogram data and start time in seconds const viewStartTime = domainStart / 1000; const viewEndTime = domainEnd / 1000; const fullSeisData = new Float32Array(seismogram.y); const seismogramStartSec = seismogram.startTime.valueOf() / 1000; - // Initialize the spectrogram model with our trimmed start time + // Initialize the spectrogram model for this particular seismogram and set the data const spectrogram = new SpectrogramModel( this.spectrogramConfig, dataSampleRate, @@ -120,6 +122,8 @@ export class Spectrogram extends Seismograph { ); spectrogram.setData(fullSeisData); + // Render the spectrogram to the canvas, using our calculated view start and end times to calculate where each chunk should be + // drawn. We use catch because the rendering is async and we don't want to block the main thread if an error occurs this.canvasRenderer.render(spectrogram, viewStartTime, viewEndTime).catch((err) => { util.warn(`Error rendering spectrogram: ${err.message}`); return; From e50a5d7a5046339c46714ba3bbb2a06d6d40075f Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 7 Aug 2026 11:44:33 -0700 Subject: [PATCH 17/24] Add better comments to createLeftRightAxis --- src/spectrogram.mts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 2edecfd9..84b82a1d 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -131,6 +131,8 @@ export class Spectrogram extends Seismograph { }); } + // We need to override the normal axis creation to be frequencies instead of amplitudes, and to constrain the frequency + // range to the Nyquist frequency override createLeftRightAxis(): Array | null> { let yAxis = null; let yAxisRight = null; @@ -143,18 +145,18 @@ export class Spectrogram extends Seismograph { const nyquist = sampleRate !== Infinity ? sampleRate / 2 : 0; const safeFMax = Math.min(this.spectrogramConfig.freqMax, nyquist); const safeFMin = Math.max(this.spectrogramConfig.freqMin, 0); + // We can use __initAmpScale because it initializes the scale range to the height of the plot - then we just need to set + // the domain to the safe frequency range + const axisScale = this.__initAmpScale().domain([safeFMin, safeFMax]); - const axisScale = this.__initAmpScale() - .domain([safeFMin, safeFMax]); + // Create the left and right axes just like the normal Seismograph, but with frequency formatting instead of amplitude formatting if (this.spectrogramConfig.isYAxis) { yAxis = d3axisLeft(axisScale).tickFormat( numberFormatWrapper(this.spectrogramConfig.frequencyFormat), ); yAxis.scale(axisScale); - yAxis.ticks(this.spectrogramConfig.yAxisNumTickHint, - this.spectrogramConfig.frequencyFormat); + yAxis.ticks(this.spectrogramConfig.yAxisNumTickHint, this.spectrogramConfig.frequencyFormat); } - if (this.spectrogramConfig.isYAxisRight) { yAxisRight = d3axisRight(axisScale).tickFormat( numberFormatWrapper(this.spectrogramConfig.frequencyFormat), From f2d0048538f0beae20768f7f8d9f9114008954c9 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 7 Aug 2026 14:20:03 -0700 Subject: [PATCH 18/24] Remove SpectrogramModel because we ultimately didn't really use it --- src/spectrogram.mts | 105 ++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 58 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 84b82a1d..afedec6b 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -114,17 +114,16 @@ export class Spectrogram extends Seismograph { const fullSeisData = new Float32Array(seismogram.y); const seismogramStartSec = seismogram.startTime.valueOf() / 1000; - // Initialize the spectrogram model for this particular seismogram and set the data - const spectrogram = new SpectrogramModel( + // Render the spectrogram to the canvas, using our calculated view start and end times to calculate where each chunk should be + // drawn. We use catch because the rendering is async and we don't want to block the main thread if an error occurs + this.canvasRenderer.render( + fullSeisData, this.spectrogramConfig, dataSampleRate, seismogramStartSec, - ); - spectrogram.setData(fullSeisData); - - // Render the spectrogram to the canvas, using our calculated view start and end times to calculate where each chunk should be - // drawn. We use catch because the rendering is async and we don't want to block the main thread if an error occurs - this.canvasRenderer.render(spectrogram, viewStartTime, viewEndTime).catch((err) => { + viewStartTime, + viewEndTime + ).catch((err) => { util.warn(`Error rendering spectrogram: ${err.message}`); return; }); @@ -168,6 +167,7 @@ export class Spectrogram extends Seismograph { } override createUnitsLabel() { + // Spectrograms are always in Hz, so we override the units label to return "Hz" if the ySublabelIsUnits flag is set if (this.spectrogramConfig && this.spectrogramConfig.ySublabelIsUnits) { return "Hz"; } @@ -182,26 +182,6 @@ export class Spectrogram extends Seismograph { } customElements.define(SPECTROGRAM_ELEMENT, Spectrogram); -class SpectrogramModel { - config: SpectrogramConfig; - sampleRate: number; - data: Float32Array | null = null; - - showRealTimeScale: boolean = false; - // Start time of the spectrogram in seconds from epoch - startTime: number = 0; - - constructor(config: SpectrogramConfig, sampleRate: number, startTime: number) { - this.config = config; - this.sampleRate = sampleRate; - this.startTime = startTime; - } - - setData(data: Float32Array) { - this.data = data; - } -} - class CanvasRenderer { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D | null = null; @@ -214,50 +194,59 @@ class CanvasRenderer { this.windowSize = canvasSize; } + /** + * Clears the cache of processed data chunks, forcing new chunks to be generated on the next render + */ clearCache() { this.chunksCache.clear(); } + /** + * Sets the canvas element to render the spectrogram onto. This method should be called before calling render() + * @param canvas The HTMLCanvasElement to render the spectrogram onto + */ setCanvas(canvas: HTMLCanvasElement) { this.canvas = canvas; } /** - * Renders the SpectrogramModel on the given canvas at the specified time and frequency ranges - * @param canvas The canvas element on which to render the spectrogram + * Renders the spectrogram data onto the set canvas according to the provided configuration and view range. This method processes the seismic data in chunks, + * applies FFT, and maps the resulting magnitudes to colors based on the specified color map. + * @param data The seismic data to render as a spectrogram + * @param config The configuration settings for the spectrogram rendering + * @param sampleRate The sample rate of the seismic data in Hz + * @param dataStartTime The start time of the seismic data in seconds from epoch * @param viewStartTime The start time of the view range in seconds from epoch * @param viewEndTime The end time of the view range in seconds from epoch * @returns A promise resolving when rendering is complete */ - async render(spectrogram: SpectrogramModel, viewStartTime: number, viewEndTime: number) { + async render(data: Float32Array, config: SpectrogramConfig, sampleRate: number, dataStartTime: number, viewStartTime: number, viewEndTime: number) { this.ctx = this.canvas.getContext("2d", { alpha: true })!; const processor = new ChunkProcessor( - spectrogram.config, - spectrogram.sampleRate, + config, + sampleRate, this.windowSize, ); - if (!this.ctx || !spectrogram.data || !spectrogram.data.length || viewStartTime >= viewEndTime) { + if (!this.ctx || !data || !data.length || viewStartTime >= viewEndTime) { return; } // Get time and frequency range from model - const dataStartTime = spectrogram.startTime; - const dataEndTime = dataStartTime + spectrogram.data?.length / spectrogram.sampleRate; - const fMin = spectrogram.config.freqMin; - const fMax = spectrogram.config.freqMax; + const dataEndTime = dataStartTime + data.length / sampleRate; + const fMin = config.freqMin; + const fMax = config.freqMax; - const colorMap = new ColorMap(spectrogram.config.spectrogramColorMap); + const colorMap = new ColorMap(config.spectrogramColorMap); this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // Calculates how big each chunk should be according to the desired time range and overlap - const sampleRate = spectrogram.sampleRate; - const overlap = Math.floor(spectrogram.config.overlapPerc * this.windowSize); + const overlap = Math.floor(config.overlapPerc * this.windowSize); const hopSize = Math.max(1, this.windowSize - overlap); // Calculates the number of samples to include in each chunk based on the desired minimum chunk // time. A minimum chunk time is often used for performance reasons - const targetSamples = spectrogram.config.minChunkTime * sampleRate; + const targetSamples = config.minChunkTime * sampleRate; const hopsPerChunk = Math.ceil(targetSamples / hopSize); const chunkSamples = hopsPerChunk * hopSize; @@ -299,10 +288,10 @@ class CanvasRenderer { // Convert the processed data to an image const imgData = processor.process( - spectrogram.data, + data, chunkStart, chunkEnd, - spectrogram.config, + config, (val: number) => colorMap.getRGB(val), ); @@ -321,8 +310,9 @@ class CanvasRenderer { this.ctx.clip(); this.drawChunk( - spectrogram, chunk, + sampleRate, + dataStartTime, viewStartTime, viewEndTime, fMin, @@ -336,33 +326,32 @@ class CanvasRenderer { /** * Draws a chunk of spectrogram within the view range - * @param ctx 2D rendering context to draw on * @param chunk DataChunk instance to draw - * @param viewStartTime Start time of the view range in seconds - * @param viewEndTime End time of the view range in seconds - * @param fMin Minimum frequency to display - * @param fMax Maximum frequency to display - * @param plotW Width of the plot area - * @param plotH Height of the plot area + * @param sampleRate Sample rate of the seismic data in Hz + * @param startTime Start time of the seismic data in seconds from epoch + * @param viewStartTime Start time of the view range in seconds from epoch + * @param viewEndTime End time of the view range in seconds from epoch + * @param fMin Minimum frequency to display in Hz + * @param fMax Maximum frequency to display in Hz */ private drawChunk( - spectrogram: SpectrogramModel, chunk: DataChunk, + sampleRate: number, + startTime: number, viewStartTime: number, viewEndTime: number, - fMin: number, // Hz - fMax: number, // Hz + fMin: number, + fMax: number, ) { if (!chunk.image) { return; } const viewDuration = viewEndTime - viewStartTime; - const sampleRate = spectrogram.sampleRate; const nyquist = sampleRate / 2; - const chunkStartTime = spectrogram.startTime + chunk.startIndex / sampleRate; - const chunkEndTime = spectrogram.startTime + chunk.endIndex / sampleRate; + const chunkStartTime = startTime + chunk.startIndex / sampleRate; + const chunkEndTime = startTime + chunk.endIndex / sampleRate; // Calculate the x-coordinates for the chunk within the plot area const plotW = this.canvas.width; From 76c20bf9ed7f8682ddaddfbc75c5746bb4dd7ade Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 7 Aug 2026 14:28:32 -0700 Subject: [PATCH 19/24] Finish comments for CanvasRenderer and DataChunk --- src/spectrogram.mts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index afedec6b..a202c059 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -115,7 +115,7 @@ export class Spectrogram extends Seismograph { const seismogramStartSec = seismogram.startTime.valueOf() / 1000; // Render the spectrogram to the canvas, using our calculated view start and end times to calculate where each chunk should be - // drawn. We use catch because the rendering is async and we don't want to block the main thread if an error occurs + // drawn. We use catch because the rendering is async and we don't want to block the main thread this.canvasRenderer.render( fullSeisData, this.spectrogramConfig, @@ -182,11 +182,14 @@ export class Spectrogram extends Seismograph { } customElements.define(SPECTROGRAM_ELEMENT, Spectrogram); +// Renderer for drawing the spectrogram on a canvas. This class handles the processing of data into spectrogram chunks and +// managing the caching of processed chunks for performance optimization class CanvasRenderer { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D | null = null; private windowSize: number; + // Cache of processed data chunks, keyed by a unique identifier for each chunk. This allows for reusing previously processed chunks private chunksCache: Map = new Map(); constructor(canvas: HTMLCanvasElement, canvasSize: number) { @@ -210,8 +213,8 @@ class CanvasRenderer { } /** - * Renders the spectrogram data onto the set canvas according to the provided configuration and view range. This method processes the seismic data in chunks, - * applies FFT, and maps the resulting magnitudes to colors based on the specified color map. + * Renders the spectrogram data onto the set canvas according to the provided configuration and view range. This method processes the data into chunks + * and creates a spectrogram image for each chunk, which is then drawn onto the canvas * @param data The seismic data to render as a spectrogram * @param config The configuration settings for the spectrogram rendering * @param sampleRate The sample rate of the seismic data in Hz @@ -350,8 +353,8 @@ class CanvasRenderer { const viewDuration = viewEndTime - viewStartTime; const nyquist = sampleRate / 2; - const chunkStartTime = startTime + chunk.startIndex / sampleRate; - const chunkEndTime = startTime + chunk.endIndex / sampleRate; + const chunkStartTime = startTime + chunk.startTime; + const chunkEndTime = startTime + chunk.endTime; // Calculate the x-coordinates for the chunk within the plot area const plotW = this.canvas.width; @@ -396,6 +399,8 @@ class CanvasRenderer { } } +// Glorified interface for storing information about a chunk of data to be processed into a spectrogram image. This class is used to cache the processed +// images for performance optimization export class DataChunk { public id: string; public startTime: number; @@ -415,6 +420,7 @@ export class DataChunk { this.id = id; this.startIndex = startIdx; this.endIndex = endIdx; + // Convert the start and end indices to times in seconds from epoch based on the sample rate for convenience when drawing the chunk on the canvas this.startTime = startIdx / sampleRate; this.endTime = endIdx / sampleRate; } From f390aa890b93351530dff4d31c072046464981d6 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 7 Aug 2026 15:10:58 -0700 Subject: [PATCH 20/24] Add copious comments to ChunkProcessor.process since it has a big chunk of the functionality --- src/spectrogram.mts | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index a202c059..d7a33b64 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -12,11 +12,13 @@ import { axisRight as d3axisRight, } from "d3-axis"; +// Types of window functions that can be applied to data chunks before performing the FFT export type WindowFunctionType = | "hann" | "hamming" | "blackman" | "rectangular"; +// Types of color maps that can be used to display the spectrogram. Each has a function below that maps normalized values to RGB colors export type ColorMapName = | "viridis" | "inferno" @@ -65,6 +67,7 @@ export class SpectrogramConfig extends SeismographConfig { } } +// Overriding Seismograph to allow for shared functionality with Seismograph, only altered for the different rendering export class Spectrogram extends Seismograph { spectrogramConfig: SpectrogramConfig; canvasRenderer: CanvasRenderer; @@ -438,6 +441,7 @@ export class ChunkProcessor { windowSize: number, ) { this.fftSize = config.fftSize; + // Used to apply a window function to each data chunk before performing the FFT - helps reduce spectral leakage in the FFT output this.windowBuffer = createWindow(windowSize, config.windowType); this.inputBuf = new Float32Array(this.fftSize); this.sampleRate = sampleRate; @@ -460,24 +464,30 @@ export class ChunkProcessor { config: SpectrogramConfig, colormapToRgb: (normalizedVal: number) => [number, number, number], ): ImageData { + // Calculate the hop size based on the number of unique samples in one window const { minDb, maxDb, overlapPerc } = config; const windowSize = this.windowBuffer.length; const overlap = Math.floor(overlapPerc * windowSize); const hopSize = Math.max(1, windowSize - overlap); + // Divide the data into these hops to get the image width because each hop represents a column in the + // spectrogram const numHops = Math.ceil((endIdx - startIdx) / hopSize); const width = numHops; + // The FFT output is symmetric, so we only need to take half of the FFT size for the DC component const height = (this.fftSize >> 1) + 1; - if (width <= 0) { + if (width <= 0) return new ImageData(1, 1); - } const imgData = new ImageData(width, height); const pixels = imgData.data; const inputBuf = this.inputBuf; const windowBuf = this.windowBuffer; + // Calculate the sum of the data in the first window for calculating the mean, which will help us remove any + // DC offset before performing the FFT. This will be a rolling sum updated per frame to keep the DC average + // accurate. This helps to center the data around zero and improves the accuracy of the FFT output let dcSum = 0; let validCount = 0; for (let i = 0; i < windowSize; i++) { @@ -488,45 +498,61 @@ export class ChunkProcessor { } } + // Loop through each hop and perform the FFT on the windowed data, then map the FFT output to RGB colors for the spectrogram for (let x = 0; x < width; x++) { + // Create the input buffer for the FFT by applying the window function to the data in the current hop, and removing the DC offset const signalStart = startIdx + x * hopSize; const mean = validCount > 0 ? dcSum / validCount : 0; - - const end = Math.min(windowSize, data.length - signalStart); let i = 0; - for (; i < end; i++) { + const currWindowSize = Math.min(windowSize, data.length - signalStart); + for (; i < currWindowSize; i++) { + // Check if the index is within the bounds of the data array and the window size if ( signalStart + i < data.length && signalStart + i >= 0 && i >= 0 && i < windowSize ) { + // If valid, apply the window function and remove the DC offset from the data point inputBuf[i] = (data[signalStart + i]! - mean) * windowBuf[i]!; } } + // If the current window size is smaller than the FFT size, fill the remaining input buffer with zeros to smooth the + // FFT output. Since the FFT size is often a power of two, this can also speed up computation for (; i < this.fftSize; i++) { inputBuf[i] = 0; } + // Execute the FFT on the windowed input buffer and get the magnitude spectrum, which will be used to create the spectrogram const fft = new FFTExecutor(this.fftSize); + // Magnitude spectrum, consisting of magnitude values for each frequency bin. The values are normalized between 0 and 1 based on + // the minDb and maxDb configuration settings. These values will be mapped to RGB colors for the spectrogram const mags = fft.compute(inputBuf, this.sampleRate, minDb, maxDb); + // Map the magnitude spectrum to RGB colors for the spectrogram image, and fill in the pixel data for the current column in the image for (let y = 0; y < height; y++) { if (y < 0 || y >= mags.length) { // Index is out of bounds for magnitude array break; } - const val = mags[y]; - const rgb = colormapToRgb(val!); + // Convert the normalized frequency value to an RGB color using the provided colormap function + const rgb = colormapToRgb(mags[y]!); + // The y-coordinate is inverted because the canvas origin is at the top-left corner, so we need to flip the y-axis to match + // the spectrogram orientation const row = height - 1 - y; + // Convert the 2D pixel coordinates to a 1D index in the ImageData array, which is in RGBA format (4 bytes per pixel) const idx = (row * width + x) * 4; pixels[idx] = rgb[0]; pixels[idx + 1] = rgb[1]; pixels[idx + 2] = rgb[2]; + // No need for alpha, so set to opaque pixels[idx + 3] = 255; } + // Remove the data points that are no longer in the window from the DC sum and add the new data points that are now in the + // window to the DC sum. This keeps the DC average accurate for each hop if (x + 1 < width) { + // Remove the data points that are no longer in the window from the DC sum and valid count for (let k = 0; k < hopSize; k++) { const outIdx = signalStart + k; if (outIdx >= 0 && outIdx < data.length) { @@ -535,6 +561,7 @@ export class ChunkProcessor { } } + // Add the new data points that are now in the window to the DC sum and valid count const nextStart = signalStart + windowSize; for (let k = 0; k < hopSize; k++) { const inIdx = nextStart + k; From 77224380397f7ff90264814f4951aaed3d9d17c4 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 7 Aug 2026 15:46:26 -0700 Subject: [PATCH 21/24] Add comments to FFTExecutor.compute --- src/spectrogram.mts | 48 +++++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index d7a33b64..96ad1019 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -587,63 +587,69 @@ class FFTExecutor { private readonly spectrum: Float32Array; constructor(fftSize: number) { - if ((fftSize & (fftSize - 1)) !== 0) { - throw new Error("FFT size must be power of two"); + if (fftSize <= 0 || fftSize % 2 !== 0) { + throw new Error("FFT size must be a positive value and a power of two"); } this.fftSize = fftSize; + // Complex input array for the FFT, which will hold the interwoven real and imaginary parts of the input signal. The spectrum array, which + // is the magnitude output of compute(), will hold the magnitude values of the FFT output, which is half the size of the FFT for the DC component this.complexIn = new Float32Array(fftSize * 2); this.spectrum = new Float32Array(fftSize / 2 + 1); } - size(): number { - return this.fftSize; - } - + /** + * Computes the FFT of the input signal and returns the magnitude spectrum normalized between 0 and 1 based on the provided minDb and maxDb values + * @param input Float32Array of length equal to fftSize + * @param sampleRate Sample rate of the input data, used to calculate the frequency bins for the output spectrum + * @param minDb Minimum dB value for normalization + * @param maxDb Maximum dB value for normalization + * @returns Float32Array of length fftSize / 2 + 1 containing the normalized magnitude spectrum + */ compute( input: Float32Array, sampleRate: number, minDb: number, maxDb: number, ): Float32Array { - const N = this.fftSize; const cin = this.complexIn; - for (let i = 0; i < N; i++) { + for (let i = 0; i < this.fftSize; i++) { const j = i << 1; cin[j] = input[i]!; + // The imaginary part is set to 0 because the input signal is real-valued. The FFT will compute the complex frequency + // components, but since the input is real, the imaginary parts are initialized to zero cin[j + 1] = 0; } - // We can use 0 for the startTime because seisplot doesn't use it for fftForward + // We can use 0 for the startTime because we're not interested in the actual time values for the FFT output const inputDisplayData = SeismogramDisplayData.fromContiguousData( cin, sampleRate, DateTime.fromMillis(0), ); + // Perform the actual FFT const out: Float32Array = fftForward(inputDisplayData).packedFreq; - const spec = this.spectrum; - - const n = spec.length; - const invRange = 1 / (maxDb - minDb); - const eps = this.EPS; - const invLn10 = this.INV_LN10; - for (let i = 0; i < n; i++) { + // Convert the FFT output to a magnitude spectrum and normalize it + for (let i = 0; i < this.spectrum.length; i++) { const realComp = out[i]; let p = 0; - // Check if valid FFT output values + // Check if valid FFT output values. If so, calculate the power of the frequency bin by squaring the real component and adding a small + // epsilon to avoid log(0) issues if (realComp !== undefined) { - p = realComp * realComp + eps; + p = realComp * realComp + this.EPS; } - const v = (10 * Math.log(p) * invLn10 - minDb) * invRange; - spec[i] = v < 0 ? 0 : v > 1 ? 1 : v; + // Convert the power to decibels and normalize it between 0 and 1 based on the provided minDb and maxDb values + const v = (10 * Math.log(p) * this.INV_LN10 - minDb) / (maxDb - minDb); + // Clamp the normalized value to be between 0 and 1 to avoid any out-of-bounds values in the spectrogram display + this.spectrum[i] = v < 0 ? 0 : v > 1 ? 1 : v; } - return spec; + return this.spectrum; } } From e62ec7c29cfdfe4276483dffe4173e5716394c57 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 7 Aug 2026 15:56:26 -0700 Subject: [PATCH 22/24] Add comments for ColorMap --- src/spectrogram.mts | 102 ++++++++++++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 36 deletions(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 96ad1019..f02b33db 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -653,6 +653,12 @@ class FFTExecutor { } } +/** + * Creates a window function of the specified type and size. Window functions are used to reduce spectral leakage in the FFT by tapering the edges of the data chunk + * @param size Size of the window array to be outputted + * @param type Type of window function to create. Can be "hann", "hamming", "blackman", or "rectangular" + * @returns Float32Array containing the window function values + */ const createWindow = (size: number, type: WindowFunctionType): Float32Array => { const window = new Float32Array(size); @@ -685,6 +691,61 @@ const createWindow = (size: number, type: WindowFunctionType): Float32Array => { return window; }; +// ColorMap class for mapping normalized values to RGB colors based on the selected color map type. Generates a +// lookup table (LUT) for efficient color mapping +export class ColorMap { + private type: ColorMapName; + private lut: Uint8Array; // [R, G, B, R, G, B...] for 0..255 + + constructor(type: ColorMapName = "jet") { + this.type = type; + this.lut = new Uint8Array(256 * 3); + this.generateLut(); + } + + /** + * Generates a LUT for the selected color map type + */ + private generateLut() { + const fn = COLOR_MAP_FNS[this.type]; + for (let i = 0; i <= 255; i++) { + const rgb = fn(i / 255); + const j = i * 3; + this.lut[j] = rgb[0]; + this.lut[j + 1] = rgb[1]; + this.lut[j + 2] = rgb[2]; + } + } + + /** + * Returns the RGB color for a normalized value t (0-1) based on the LUT. If t is out of bounds, returns black + * @param t Normalized value (0-1) to map to an RGB color + * @returns RGB color as an array of three numbers [R, G, B] where each component is in the range 0-255 + */ + getRGB(t: number): RGB { + const idx = (t <= 0 ? 0 : t >= 1 ? 255 : (t * 255) | 0) * 3; + if (idx < 0 || idx + 2 >= this.lut.length) { + return [0, 0, 0]; + } + return [this.lut[idx]!, this.lut[idx + 1]!, this.lut[idx + 2]!]; + } + + /** + * Sets the color map type and regenerates the lookup table + * @param type String name of the color map to use. Must be one of the supported color map names + */ + setMap(type: ColorMapName) { + this.type = type; + this.generateLut(); + } +} + +/** + * Interpolates a color from a color map based on a normalized value + * @param t Normalized value (0-1) + * @param map Array of RGB colors + * @returns Interpolated RGB color + */ function interpolateColorMap(t: number, map: number[][]): RGB { if (t <= 0) { return map[0] as RGB; @@ -694,9 +755,12 @@ function interpolateColorMap(t: number, map: number[][]): RGB { } const step = 1 / (map.length - 1); + // Bitwise OR with 0 to convert Infinity or NaN to 0 to avoid issues with the index calculation const idx = (t / step) | 0; + // Calculate the percentage within the current step for interpolation between two colors const localT = (t - idx * step) / step; + // Get the two colors to interpolate between based on the calculated index const c1 = map[idx]; const c2 = map[idx + 1]; @@ -704,6 +768,7 @@ function interpolateColorMap(t: number, map: number[][]): RGB { return [0, 0, 0]; } + // Interpolate between the two colors using linear interpolation, again using avoiding NaN or Infinity issues by using bitwise OR return [ (c1[0]! + (c2[0]! - c1[0]!) * localT) | 0, (c1[1]! + (c2[1]! - c1[1]!) * localT) | 0, @@ -866,39 +931,4 @@ const COLOR_MAP_FNS: Record RGB> = { autumn, winter, bone, -}; - -export class ColorMap { - private type: ColorMapName; - private lut: Uint8Array; // [R, G, B, R, G, B...] for 0..255 - - constructor(type: ColorMapName = "jet") { - this.type = type; - this.lut = new Uint8Array(256 * 3); - this.generateLut(); - } - - private generateLut() { - const fn = COLOR_MAP_FNS[this.type]; - for (let i = 0; i < 256; i++) { - const rgb = fn(i / 255); - const j = i * 3; - this.lut[j] = rgb[0]; - this.lut[j + 1] = rgb[1]; - this.lut[j + 2] = rgb[2]; - } - } - - getRGB(t: number): RGB { - const idx = (t <= 0 ? 0 : t >= 1 ? 255 : (t * 255) | 0) * 3; - if (idx < 0 || idx + 2 >= this.lut.length) { - return [0, 0, 0]; - } - return [this.lut[idx]!, this.lut[idx + 1]!, this.lut[idx + 2]!]; - } - - setMap(type: ColorMapName) { - this.type = type; - this.generateLut(); - } -} +}; \ No newline at end of file From 32a619a2d00fecbf8c95997d3b6c5dfa634c1166 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 7 Aug 2026 16:12:19 -0700 Subject: [PATCH 23/24] Add the MIT license for using spectrogram-js code --- src/spectrogram.mts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index f02b33db..63a60da6 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -1,3 +1,30 @@ +/* +Gavin Bullock +Pacific Northwest Seismic Network, 2026 +https://pnsn.org + +Code used from: spectrogram-js - Copyright (c) 2025 AnyShake Project +Licensed under the MIT License. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + import { DateTime } from "luxon"; import { SeismogramDisplayData } from "./seismogram.mjs"; import { Seismograph } from "./seismograph.mjs"; From fe240a97a1b7633452cda3f0b23eafa28269daa7 Mon Sep 17 00:00:00 2001 From: Gavin Bullock Date: Fri, 7 Aug 2026 17:19:09 -0700 Subject: [PATCH 24/24] Default to PNSN power range for config --- src/spectrogram.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spectrogram.mts b/src/spectrogram.mts index 63a60da6..54877cee 100644 --- a/src/spectrogram.mts +++ b/src/spectrogram.mts @@ -83,7 +83,7 @@ export class SpectrogramConfig extends SeismographConfig { // Lower and upper bounds for spectrogram color scaling in decibels. FFT power values below minDb are shown as the darkest // color, and values above maxDb are shown as the brightest color. minDb: number = 30; - maxDb: number = 150; + maxDb: number = 120; // Color map for spectrogram display spectrogramColorMap: ColorMapName = "jet";