/ proof-of-concept / phase-perception-2
Sign in
proof-of-concept / phase-perception-2
phase-perception-2 / src / components / SpectrogramPlot.tsx
91 lines · 2.8 KBBlameHistoryRaw
1import { useCallback, useMemo } from 'react'
2import { useCanvas } from '../hooks/useCanvas'
3import { AXIS, FONT, FRAME, formatTick, magma, niceTicks } from '../plot'
4import type { SpectrogramData } from '../types'
6type Props = {
7 spec: SpectrogramData
8 /** dB below the shared maximum shown as black */
9 dbRange: number
10 label: string
11 color: string
14const M = { l: 48, r: 12, t: 8, b: 26 }
16/** Draws a dB spectrogram as an image with time and frequency axes. */
17export function SpectrogramPlot({ spec, dbRange, label, color }: Props) {
18 // Rasterize once per spectrogram, then scale to the canvas on every draw.
19 const image = useMemo(() => {
20 const { rows, cols, db } = spec
21 const off = document.createElement('canvas')
22 off.width = cols
23 off.height = rows
24 const ictx = off.getContext('2d')
25 if (!ictx) return null
26 const img = ictx.createImageData(cols, rows)
27 const d = img.data
28 for (let r = 0; r < rows; r++) {
29 const yImg = rows - 1 - r // row 0 (0 Hz) at the bottom
30 for (let c = 0; c < cols; c++) {
31 const v = db[r * cols + c]
32 const [R, G, B] = magma(1 + v / dbRange)
33 const o = 4 * (yImg * cols + c)
34 d[o] = R
35 d[o + 1] = G
36 d[o + 2] = B
37 d[o + 3] = 255
38 }
39 }
40 ictx.putImageData(img, 0, 0)
41 return off
42 }, [spec, dbRange])
44 const draw = useCallback(
45 (ctx: CanvasRenderingContext2D, w: number, h: number) => {
46 const pw = w - M.l - M.r
47 const ph = h - M.t - M.b
48 ctx.font = FONT
49 if (image) {
50 ctx.imageSmoothingEnabled = false
51 ctx.drawImage(image, M.l, M.t, pw, ph)
52 }
53 ctx.strokeStyle = FRAME
54 ctx.lineWidth = 1
55 ctx.strokeRect(M.l + 0.5, M.t + 0.5, pw, ph)
57 ctx.fillStyle = AXIS
58 ctx.textAlign = 'right'
59 ctx.textBaseline = 'middle'
60 for (const v of niceTicks(0, spec.fmax, 4)) {
61 if (v > spec.fmax) continue
62 const y = M.t + ph - (v / spec.fmax) * ph
63 ctx.fillText(formatTick(v), M.l - 5, y)
64 }
65 ctx.textAlign = 'center'
66 ctx.textBaseline = 'top'
67 for (const v of niceTicks(0, spec.durationSec, 6)) {
68 if (v > spec.durationSec) continue
69 const x = M.l + (v / spec.durationSec) * pw
70 ctx.fillText(formatTick(v), x, M.t + ph + 4)
71 }
72 ctx.textBaseline = 'bottom'
73 ctx.fillText('time (s)', M.l + pw / 2, h - 1)
74 ctx.save()
75 ctx.translate(11, M.t + ph / 2)
76 ctx.rotate(-Math.PI / 2)
77 ctx.textBaseline = 'middle'
78 ctx.fillText('Hz', 0, 0)
79 ctx.restore()
81 ctx.fillStyle = color
82 ctx.font = 'bold 12px system-ui, sans-serif'
83 ctx.textAlign = 'left'
84 ctx.textBaseline = 'top'
85 ctx.fillText(label, M.l + 6, M.t + 5)
86 },
87 [image, spec, label, color],
88 )
89 const ref = useCanvas(draw)
90 return <canvas ref={ref} className="plot spectrogram-plot" />
↑↓ move↵openescclose