/ concept-collection / timeseries-compressibility
Sign in
concept-collection / timeseries-compressibility
Snap every parameter slider to a ladder of round values
Jeremy Magland <jmagland@flatironinstitute.org> committed commit fb1a12fad04d parent 8a09dfc Browse files
3 changed files+115−63
src/App.tsxmodified+7−2View file
@@ -4,7 +4,7 @@ import FilterViz from './components/FilterViz'
44 import ScrollingView from './components/ScrollingView'
55 import CompressionChart from './components/CompressionChart'
66 import MathSection from './components/MathSection'
7-import { DEFAULT_SPEC, designKernel, kernelNorm } from './model/filters'
7+import { DEFAULT_SPEC, clampSpec, designKernel, kernelNorm } from './model/filters'
88 import { theoreticalRateBits } from './model/theory'
99 import type { CodecResult } from './compress/codecs'
1010 import type { CompressRequest, CompressResponse } from './worker/compressWorker'
@@ -102,7 +102,12 @@ export default function App() {
102102 sigma={sigma}
103103 setSigma={setSigma}
104104 sampleRateHz={sampleRateHz}
105- setSampleRateHz={setSampleRateHz}
105+ setSampleRateHz={rate => {
106+ // Band edges are absolute, so a new rate can push them past
107+ // Nyquist; re-snap the spec so sliders and kernel stay in step.
108+ setSampleRateHz(rate)
109+ setSpec(s => clampSpec(s, rate))
110+ }}
106111 spec={spec}
107112 setSpec={setSpec}
108113 dither={dither}
src/components/Controls.tsxmodified+52−56View file
@@ -1,4 +1,14 @@
1-import { FAMILY_LABELS, type FilterFamily, type FilterSpec } from '../model/filters'
1+import {
2+ FAMILY_LABELS,
3+ SIGMA_STOPS,
4+ TAP_STOPS,
5+ WIDTH_STOPS,
6+ frequencyStops,
7+ maxCutoffHz,
8+ nearestStop,
9+ type FilterFamily,
10+ type FilterSpec,
11+} from '../model/filters'
212
313 export interface ControlsProps {
414 sigma: number
@@ -12,32 +22,32 @@ export interface ControlsProps {
1222 }
1323
1424 function formatHz(hz: number): string {
15- return hz >= 1000 ? `${(hz / 1000).toFixed(hz >= 10000 ? 0 : 1)} kHz` : `${Math.round(hz)} Hz`
25+ return hz >= 1000 ? `${+(hz / 1000).toFixed(1)} kHz` : `${hz} Hz`
1626 }
1727
18-/** A slider that is linear in log10 of its value. */
19-function LogSlider(props: {
28+/** A slider that moves between the given values rather than a continuum. */
29+function StopSlider(props: {
2030 label: string
31+ stops: number[]
2132 value: number
22- min: number
23- max: number
24- display: string
33+ format: (v: number) => string
2534 onChange: (v: number) => void
2635 }) {
27- const lo = Math.log10(props.min)
28- const hi = Math.log10(props.max)
36+ const { stops } = props
37+ const snapped = nearestStop(stops, props.value)
38+ const index = stops.indexOf(snapped)
2939 return (
3040 <div className="control">
3141 <label>
32- {props.label} <span className="value">{props.display}</span>
42+ {props.label} <span className="value">{props.format(snapped)}</span>
3343 </label>
3444 <input
3545 type="range"
36- min={lo}
37- max={hi}
38- step={(hi - lo) / 400}
39- value={Math.log10(props.value)}
40- onChange={e => props.onChange(10 ** Number(e.target.value))}
46+ min={0}
47+ max={stops.length - 1}
48+ step={1}
49+ value={index}
50+ onChange={e => props.onChange(stops[Number(e.target.value)])}
4151 />
4252 </div>
4353 )
@@ -45,17 +55,16 @@ function LogSlider(props: {
4555
4656 export default function Controls(p: ControlsProps) {
4757 const { spec } = p
48- const nyquist = p.sampleRateHz / 2
58+ const freqStops = frequencyStops(maxCutoffHz(p.sampleRateHz))
4959 const sinc = spec.family === 'lowpass' || spec.family === 'bandpass'
5060 return (
5161 <div className="controls">
52- <LogSlider
62+ <StopSlider
5363 label="σ (quantization steps)"
64+ stops={SIGMA_STOPS}
5465 value={p.sigma}
55- min={0.1}
56- max={100}
57- display={p.sigma.toPrecision(3)}
58- onChange={v => p.setSigma(Number(v.toPrecision(3)))}
66+ format={v => String(v)}
67+ onChange={p.setSigma}
5968 />
6069 <div className="control">
6170 <label>filter</label>
@@ -71,53 +80,40 @@ export default function Controls(p: ControlsProps) {
7180 </select>
7281 </div>
7382 {spec.family === 'bandpass' && (
74- <LogSlider
83+ <StopSlider
7584 label="low cutoff"
85+ stops={freqStops.filter(f => f < spec.highHz)}
7686 value={spec.lowHz}
77- min={10}
78- max={nyquist * 0.9}
79- display={formatHz(spec.lowHz)}
80- onChange={v => p.setSpec({ ...spec, lowHz: Math.round(v) })}
87+ format={formatHz}
88+ onChange={v => p.setSpec({ ...spec, lowHz: v })}
8189 />
8290 )}
8391 {sinc && (
84- <LogSlider
92+ <StopSlider
8593 label={spec.family === 'bandpass' ? 'high cutoff' : 'cutoff'}
94+ stops={spec.family === 'bandpass' ? freqStops.filter(f => f > spec.lowHz) : freqStops}
8695 value={spec.highHz}
87- min={20}
88- max={nyquist * 0.98}
89- display={formatHz(spec.highHz)}
90- onChange={v => p.setSpec({ ...spec, highHz: Math.round(v) })}
96+ format={formatHz}
97+ onChange={v => p.setSpec({ ...spec, highHz: v })}
9198 />
9299 )}
93100 {sinc && (
94- <div className="control">
95- <label>
96- kernel length <span className="value">{spec.taps} taps</span>
97- </label>
98- <input
99- type="range"
100- min={15}
101- max={201}
102- step={2}
103- value={spec.taps}
104- onChange={e => p.setSpec({ ...spec, taps: Number(e.target.value) })}
105- />
106- </div>
101+ <StopSlider
102+ label="kernel length"
103+ stops={TAP_STOPS}
104+ value={spec.taps}
105+ format={v => `${v} taps`}
106+ onChange={v => p.setSpec({ ...spec, taps: v })}
107+ />
107108 )}
108109 {spec.family === 'movingAverage' && (
109- <div className="control">
110- <label>
111- width <span className="value">{spec.width} samples</span>
112- </label>
113- <input
114- type="range"
115- min={2}
116- max={64}
117- value={spec.width}
118- onChange={e => p.setSpec({ ...spec, width: Number(e.target.value) })}
119- />
120- </div>
110+ <StopSlider
111+ label="width"
112+ stops={WIDTH_STOPS}
113+ value={spec.width}
114+ format={v => `${v} samples`}
115+ onChange={v => p.setSpec({ ...spec, width: v })}
116+ />
121117 )}
122118 <div className="control">
123119 <label>sample rate</label>
src/model/filters.tsmodified+56−5View file
@@ -38,6 +38,48 @@ export const DEFAULT_SPEC: FilterSpec = {
3838 width: 8,
3939 }
4040
41+/**
42+ * Every control snaps to a ladder of round values — a slider that stops on
43+ * 6 kHz and 101 taps rather than 5847 Hz and 97. σ is the 1-2-5 decade
44+ * ladder; frequency adds 3, 4, 6, 8 so the usual band edges are reachable.
45+ */
46+export const SIGMA_STOPS = [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100]
47+export const TAP_STOPS = [9, 15, 21, 31, 45, 65, 101, 151, 201, 301]
48+export const WIDTH_STOPS = [2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32, 48, 64]
49+
50+const FREQ_DECADE = [1, 1.5, 2, 3, 4, 5, 6, 8]
51+
52+/** Round frequencies from 10 Hz up to `maxHz`. */
53+export function frequencyStops(maxHz: number): number[] {
54+ const out: number[] = []
55+ for (let decade = 10; decade <= 1e5; decade *= 10) {
56+ for (const m of FREQ_DECADE) {
57+ const v = m * decade
58+ if (v <= maxHz) out.push(v)
59+ }
60+ }
61+ return out
62+}
63+
64+/** The stop nearest `v` (in log distance, so relative error is what counts). */
65+export function nearestStop(stops: number[], v: number): number {
66+ let best = stops[0]
67+ let bestErr = Infinity
68+ for (const s of stops) {
69+ const err = Math.abs(Math.log(s / v))
70+ if (err < bestErr) {
71+ bestErr = err
72+ best = s
73+ }
74+ }
75+ return best
76+}
77+
78+/** The highest band edge the sample rate allows a stop to sit at. */
79+export function maxCutoffHz(sampleRateHz: number): number {
80+ return sampleRateHz * 0.49
81+}
82+
4183 /** Hamming-windowed sinc lowpass with unit DC gain; fc in cycles/sample. */
4284 function windowedSincLowpass(fc: number, taps: number): Float64Array {
4385 const n = taps | 1
@@ -55,12 +97,21 @@ function windowedSincLowpass(fc: number, taps: number): Float64Array {
5597 return h
5698 }
5799
58-/** Clamp the spec's band edges into (0, Nyquist) for the given sample rate. */
100+/**
101+ * Snap the spec onto the control ladders and keep the band edges ordered and
102+ * below Nyquist — so what the sliders show is exactly what is designed.
103+ */
59104 export function clampSpec(spec: FilterSpec, sampleRateHz: number): FilterSpec {
60- const nyq = sampleRateHz / 2
61- const highHz = Math.min(Math.max(spec.highHz, 2), nyq * 0.98)
62- const lowHz = Math.min(Math.max(spec.lowHz, 1), highHz * 0.9)
63- return { ...spec, highHz, lowHz }
105+ const stops = frequencyStops(maxCutoffHz(sampleRateHz))
106+ const highHz = nearestStop(stops, spec.highHz)
107+ const below = stops.filter(f => f < highHz)
108+ return {
109+ ...spec,
110+ highHz,
111+ lowHz: below.length > 0 ? nearestStop(below, spec.lowHz) : highHz / 2,
112+ taps: nearestStop(TAP_STOPS, spec.taps),
113+ width: nearestStop(WIDTH_STOPS, spec.width),
114+ }
64115 }
65116
66117 export function designKernel(spec: FilterSpec, sampleRateHz: number): Float64Array {
moveopenescclose