36e8ceaInteractive explorer for compressibility of quantized filtered Gaussian time seriesJeremy Magland 1import { useMemo, useRef, useState } from 'react'
2import { magnitudeResponse } from '../model/filters'
3import { useWidth } from './useWidth'
8a09dfcPin the parameter bar, put compression first, shrink signal and filter panelsJeremy Magland 5const MARGIN = { left: 44, right: 12, top: 10, bottom: 24 }
6const HEIGHT = 120
7const DB_FLOOR = -80
8const DB_TICKS = [0, -20, -40, -60, -80]
36e8ceaInteractive explorer for compressibility of quantized filtered Gaussian time seriesJeremy Magland 9const RESPONSE_POINTS = 512
11interface Tip {
12 x: number
13 y: number
14 value: string
15 label: string
16}
18function Tooltip({ tip }: { tip: Tip | null }) {
19 if (!tip) return null
20 return (
21 <div className="viz-tooltip" style={{ left: tip.x + 12, top: tip.y - 10 }}>
22 <span className="tip-value">{tip.value}</span> <span className="tip-label">{tip.label}</span>
23 </div>
24 )
25}
27/** ~n round tick values covering [lo, hi]. */
28function ticks(lo: number, hi: number, n: number): number[] {
29 const span = hi - lo
30 if (!(span > 0)) return [lo]
31 const raw = span / n
32 const mag = 10 ** Math.floor(Math.log10(raw))
33 const step = [1, 2, 2.5, 5, 10].map(m => m * mag).find(s => span / s <= n) ?? raw
34 const out: number[] = []
35 for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) out.push(v)
36 return out
37}
39function formatHz(hz: number): string {
40 return hz >= 1000 ? `${+(hz / 1000).toFixed(1)}k` : `${Math.round(hz)}`
41}
43/** The convolution kernel, tap value against time. */
44function KernelPanel({ kernel, sampleRateHz }: { kernel: Float64Array; sampleRateHz: number }) {
45 const ref = useRef<HTMLDivElement>(null)
46 const width = useWidth(ref)
47 const [tip, setTip] = useState<Tip | null>(null)
49 const L = kernel.length
50 const mid = (L - 1) / 2
51 const msAt = (i: number) => ((i - mid) / sampleRateHz) * 1000
52 // A one-tap kernel still needs a nonzero span to draw on.
53 const tSpan = L > 1 ? msAt(L - 1) - msAt(0) : 1000 / sampleRateHz
54 const t0 = msAt(0) - (L === 1 ? tSpan / 2 : 0)
55 const plotW = width - MARGIN.left - MARGIN.right
56 const plotH = HEIGHT - MARGIN.top - MARGIN.bottom
57 let yMin = 0
58 let yMax = 0
59 for (const v of kernel) {
60 yMin = Math.min(yMin, v)
61 yMax = Math.max(yMax, v)
62 }
63 const pad = (yMax - yMin || 1) * 0.1
64 yMin -= pad
65 yMax += pad
66 const xOf = (i: number) => MARGIN.left + ((msAt(i) - t0) / tSpan) * plotW
67 const yOf = (v: number) => MARGIN.top + ((yMax - v) / (yMax - yMin)) * plotH
69 const path = Array.from(kernel, (v, i) => `${i ? 'L' : 'M'}${xOf(i).toFixed(1)},${yOf(v).toFixed(1)}`).join('')
71 const onMove = (e: React.PointerEvent<SVGSVGElement>) => {
72 const box = e.currentTarget.getBoundingClientRect()
73 const px = e.clientX - box.left
74 const i = L === 1 ? 0 : Math.max(0, Math.min(L - 1, Math.round(((px - MARGIN.left) / plotW) * (L - 1))))
75 setTip({
76 x: px,
77 y: e.clientY - box.top,
78 value: kernel[i].toPrecision(3),
79 label: `tap ${i} · ${msAt(i).toFixed(2)} ms`,
80 })
81 }
83 return (
84 <div className="panel" ref={ref}>
85 <h3>Convolution kernel h</h3>
86 <svg width={width} height={HEIGHT} onPointerMove={onMove} onPointerLeave={() => setTip(null)}>
87 <line
88 x1={MARGIN.left}
89 x2={width - MARGIN.right}
90 y1={yOf(0)}
91 y2={yOf(0)}
92 stroke="var(--baseline)"
93 strokeWidth={1}
94 />
95 {ticks(t0, t0 + tSpan, 5)
96 .filter(t => MARGIN.left + ((t - t0) / tSpan) * plotW < width - MARGIN.right - 34)
97 .map(t => (
98 <text key={t} x={MARGIN.left + ((t - t0) / tSpan) * plotW} y={HEIGHT - 8} textAnchor="middle" className="axis-tick">
99 {+t.toFixed(2)}
100 </text>
101 ))}
102 <text x={width - MARGIN.right} y={HEIGHT - 8} textAnchor="end" className="axis-tick">
103 ms
104 </text>
105 {ticks(yMin, yMax, 3).map(v => (
106 <text key={v} x={MARGIN.left - 6} y={yOf(v) + 4} textAnchor="end" className="axis-tick">
107 {+v.toPrecision(2)}
108 </text>
109 ))}
110 <path d={path} fill="none" stroke="var(--series-1)" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
111 {L <= 31 &&
112 Array.from(kernel, (v, i) => (
113 <circle key={i} cx={xOf(i)} cy={yOf(v)} r={4} fill="var(--series-1)" stroke="var(--surface)" strokeWidth={2} />
114 ))}
115 </svg>
116 <Tooltip tip={tip} />
117 </div>
118 )
119}
121/** |H(f)| in dB up to Nyquist, with the S(f) = 1 step² threshold. */
122function ResponsePanel({
123 kernel,
124 sampleRateHz,
125 sigma,
126}: {
127 kernel: Float64Array
128 sampleRateHz: number
129 sigma: number
130}) {
131 const ref = useRef<HTMLDivElement>(null)
132 const width = useWidth(ref)
133 const [tip, setTip] = useState<Tip | null>(null)
135 const db = useMemo(() => {
136 const mag = magnitudeResponse(kernel, RESPONSE_POINTS)
137 return Array.from(mag, m => Math.max(DB_FLOOR, 20 * Math.log10(Math.max(m, 1e-12))))
138 }, [kernel])
140 const nyquist = sampleRateHz / 2
141 const plotW = width - MARGIN.left - MARGIN.right
142 const plotH = HEIGHT - MARGIN.top - MARGIN.bottom
143 const dbMax = Math.max(5, Math.ceil(Math.max(...db) / 5) * 5)
144 const yOf = (v: number) => MARGIN.top + ((dbMax - v) / (dbMax - DB_FLOOR)) * plotH
145 const xOf = (i: number) => MARGIN.left + (i / (RESPONSE_POINTS - 1)) * plotW
147 const path = db.map((v, i) => `${i ? 'L' : 'M'}${xOf(i).toFixed(1)},${yOf(v).toFixed(1)}`).join('')
149 // S(f) = σ²|H|² = 1 (one step² of spectral power) sits at |H| = 1/σ. Below
150 // this line the high-resolution formula is on thin ice.
151 const stepDb = -20 * Math.log10(sigma)
152 const showStep = stepDb < dbMax && stepDb > DB_FLOOR
154 const onMove = (e: React.PointerEvent<SVGSVGElement>) => {
155 const box = e.currentTarget.getBoundingClientRect()
156 const px = e.clientX - box.left
157 const i = Math.max(0, Math.min(RESPONSE_POINTS - 1, Math.round(((px - MARGIN.left) / plotW) * (RESPONSE_POINTS - 1))))
158 setTip({
159 x: px,
160 y: e.clientY - box.top,
161 value: `${db[i].toFixed(1)} dB`,
162 label: `at ${formatHz((i / (RESPONSE_POINTS - 1)) * nyquist)} Hz`,
163 })
164 }
166 return (
167 <div className="panel" ref={ref}>
168 <h3>Frequency response |H(f)|</h3>
169 <svg width={width} height={HEIGHT} onPointerMove={onMove} onPointerLeave={() => setTip(null)}>
8a09dfcPin the parameter bar, put compression first, shrink signal and filter panelsJeremy Magland 170 {DB_TICKS.map(v => (
36e8ceaInteractive explorer for compressibility of quantized filtered Gaussian time seriesJeremy Magland 171 <g key={v}>
172 <line x1={MARGIN.left} x2={width - MARGIN.right} y1={yOf(v)} y2={yOf(v)} stroke="var(--grid)" strokeWidth={1} />
173 <text x={MARGIN.left - 6} y={yOf(v) + 4} textAnchor="end" className="axis-tick">
174 {v}
175 </text>
176 </g>
177 ))}
178 <text x={MARGIN.left - 6} y={MARGIN.top - 1} textAnchor="end" className="axis-tick">
179 dB
180 </text>
181 {ticks(0, nyquist, 5)
182 // Leave the right corner to the unit label.
183 .filter(f => MARGIN.left + (f / nyquist) * plotW < width - MARGIN.right - 34)
184 .map(f => (
185 <text key={f} x={MARGIN.left + (f / nyquist) * plotW} y={HEIGHT - 8} textAnchor="middle" className="axis-tick">
186 {formatHz(f)}
187 </text>
188 ))}
189 <text x={width - MARGIN.right} y={HEIGHT - 8} textAnchor="end" className="axis-tick">
190 Hz
191 </text>
192 {showStep && (
193 <g>
194 <line
195 x1={MARGIN.left}
196 x2={width - MARGIN.right}
197 y1={yOf(stepDb)}
198 y2={yOf(stepDb)}
199 stroke="var(--muted)"
200 strokeWidth={1}
201 strokeDasharray="4 3"
202 />
203 <text x={width - MARGIN.right} y={yOf(stepDb) - 4} textAnchor="end" className="axis-tick">
204 S(f) = 1 step²
205 </text>
206 </g>
207 )}
208 <path d={path} fill="none" stroke="var(--series-1)" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
209 </svg>
210 <Tooltip tip={tip} />
211 </div>
212 )
213}
215export default function FilterViz(props: { kernel: Float64Array; sampleRateHz: number; sigma: number }) {
216 return (
217 <div className="filter-panels">
218 <KernelPanel kernel={props.kernel} sampleRateHz={props.sampleRateHz} />
219 <ResponsePanel kernel={props.kernel} sampleRateHz={props.sampleRateHz} sigma={props.sigma} />
220 </div>
221 )
222}