1import { useEffect, useMemo, useRef, useState } from 'react'
2import CopyableCommand from './components/CopyableCommand'
3import Controls from './components/Controls'
4import FilterViz from './components/FilterViz'
5import ScrollingView from './components/ScrollingView'
6import CompressionChart from './components/CompressionChart'
7import MethodNote from './components/MethodNote'
8import { useEntropyRate } from './components/useEntropyRate'
9import { predictEntropyRate } from './entropy'
10import { DEFAULT_SPEC, clampSpec, designKernel, kernelNorm } from './model/filters'
11import { LATENT_SEED } from './model/latent'
12import { DEFAULT_LPC_ORDER, LPC_ORDERS } from './compress/codecs'
13import type { CodecResult } from './compress/codecs'
14import type { CompressRequest, CompressResponse } from './worker/compressWorker'
16const BLOCK_SIZES = [10000, 20000, 50000, 100000, 200000, 500000, 1000000]
17const DEFAULT_BLOCK_SIZE = 100000
19interface CompressionState {
20 results: CodecResult[]
21 empiricalStd: number
22 computing: boolean
23 error: string | null
24}
26/** The ten codec sizes, measured in a worker on a debounced parameter set. */
27function useCompression(
28 kernel: Float64Array,
29 sigma: number,
30 lpcOrder: number,
31 blockSize: number,
32): CompressionState {
33 const [state, setState] = useState<CompressionState>({
34 results: [],
35 empiricalStd: 0,
36 computing: true,
37 error: null,
38 })
39 const workerRef = useRef<Worker | null>(null)
40 const idRef = useRef(0)
42 useEffect(() => {
43 const worker = new Worker(new URL('./worker/compressWorker.ts', import.meta.url), {
44 type: 'module',
45 })
46 worker.onmessage = (e: MessageEvent<CompressResponse>) => {
47 if (e.data.id !== idRef.current) return
48 setState({
49 results: e.data.error ? [] : e.data.results,
50 empiricalStd: e.data.empiricalStd,
51 computing: false,
52 error: e.data.error ?? null,
53 })
54 }
55 workerRef.current = worker
56 return () => {
57 worker.terminate()
58 workerRef.current = null
59 }
60 }, [])
62 useEffect(() => {
63 setState(s => ({ ...s, computing: true }))
64 const id = ++idRef.current
65 const timer = setTimeout(() => {
66 const request: CompressRequest = {
67 id,
68 kernel,
69 sigma,
70 blockSize,
71 lpcOrder,
72 // The same seed the signal view draws from, so the block really is
73 // the data on screen.
74 seed: LATENT_SEED,
75 }
76 workerRef.current?.postMessage(request)
77 }, 250)
78 return () => clearTimeout(timer)
79 }, [kernel, sigma, lpcOrder, blockSize])
81 return state
82}
84/**
85 * The terminal command that estimates the entropy rate R at the current
86 * settings, using the unbiased Monte-Carlo estimator from the companion
87 * timeseries-entropy package.
88 */
89function mcCommand(sigma: number, spec: ReturnType<typeof clampSpec>, rate: number): string {
90 const parts = [
91 'uvx --from git+https://github.com/concept-collection/timeseries-entropy',
92 'timeseries-entropy',
93 `--sigma ${sigma}`,
94 ]
95 switch (spec.family) {
96 case 'none':
97 parts.push('--filter none')
98 break
99 case 'movingAverage':
100 parts.push('--filter moving-average', `--width ${spec.width}`)
101 break
102 case 'lowpass':
103 parts.push('--filter lowpass', `--high ${spec.highHz}`, `--taps ${spec.taps}`, `--rate ${rate}`)
104 break
105 case 'bandpass':
106 parts.push(
107 '--filter bandpass',
108 `--low ${spec.lowHz}`,
109 `--high ${spec.highHz}`,
110 `--taps ${spec.taps}`,
111 `--rate ${rate}`,
112 )
113 break
114 case 'firstDifference':
115 parts.push('--filter first-difference')
116 break
117 }
118 return parts.join(' ')
119}
121export default function App() {
122 const [sigma, setSigma] = useState(5)
123 const [sampleRateHz, setSampleRateHz] = useState(30000)
124 const [spec, setSpec] = useState(DEFAULT_SPEC)
125 const [lpcOrder, setLpcOrder] = useState(DEFAULT_LPC_ORDER)
126 const [blockSize, setBlockSize] = useState(DEFAULT_BLOCK_SIZE)
128 const kernel = useMemo(() => designKernel(spec, sampleRateHz), [spec, sampleRateHz])
129 const sigmaY = useMemo(() => sigma * kernelNorm(kernel), [kernel, sigma])
130 const compression = useCompression(kernel, sigma, lpcOrder, blockSize)
131 const entropyRate = useEntropyRate(kernel, sigma)
132 const theoryBits = useMemo(() => predictEntropyRate(kernel, sigma), [kernel, sigma])
134 return (
135 <div className="app">
136 <header className="app-header">
137 <h1>Time-series compressibility</h1>
138 <p>
139 Gaussian noise → FIR filter → round to integers. How well can the integer stream be
140 losslessly compressed, and how close do practical codecs get to the entropy rate of
141 the process?
142 </p>
143 </header>
145 {/* The controls stay pinned so the parameters and the ratios they move
146 are always on screen together, whatever is scrolled to. */}
147 <section className="card control-bar">
148 <Controls
149 sigma={sigma}
150 setSigma={setSigma}
151 sampleRateHz={sampleRateHz}
152 setSampleRateHz={rate => {
153 // Band edges are absolute, so a new rate can push them past
154 // Nyquist; re-snap the spec so sliders and kernel stay in step.
155 setSampleRateHz(rate)
156 setSpec(s => clampSpec(s, rate))
157 }}
158 spec={spec}
159 setSpec={setSpec}
160 />
161 </section>
163 <section className="card">
164 <h2>Compression</h2>
165 {/* Two readouts of the same number, each keyed to its chart line by a
166 sample of that line's own stroke. */}
167 <div className="stat-row">
168 <div className="stat">
169 <span className="label">
170 <span className="line-swatch" style={{ borderTop: '2px dotted var(--theory)' }} />
171 entropy rate R — analytic theory
172 </span>
173 <span className="value">
174 {theoryBits.toFixed(2)} <small>bits/sample</small>
175 </span>
176 <span className="stat-sub">
177 {theoryBits > 0 ? `best possible ratio ${(16 / theoryBits).toFixed(2)}×` : '—'}
178 </span>
179 </div>
180 <div className="stat">
181 <span className="label">
182 <span className="line-swatch" style={{ borderTop: '2px dashed var(--ink-2)' }} />
183 entropy rate R — Monte-Carlo ground truth
184 </span>
185 <span className="value">
186 {entropyRate.mean !== null ? entropyRate.mean.toFixed(2) : '—'}
187 {entropyRate.se !== null && <small> ± {entropyRate.se.toFixed(2)}</small>}{' '}
188 <small>bits/sample</small>
189 </span>
190 <span className="stat-sub">
191 {entropyRate.mean !== null && entropyRate.mean > 0
192 ? `best possible ratio ${(16 / entropyRate.mean).toFixed(2)}×`
193 : 'run the estimate to check the theory'}
194 </span>
195 {/* The estimate lives with its readout: start, watch it refine,
196 stop; a model change resets it. */}
197 <span className="stat-action">
198 <button onClick={entropyRate.running ? entropyRate.stop : entropyRate.start}>
199 {entropyRate.running ? 'stop' : entropyRate.perPast.length > 0 ? 'refine' : 'estimate'}
200 </button>
201 {/* Each independent past contributes one unbiased estimate;
202 the readout is their average, so that is the word used. */}
203 <span className="estimate-status">
204 {entropyRate.running
205 ? `${entropyRate.perPast.length} estimates · ${entropyRate.progress ?? 'starting…'}`
206 : entropyRate.perPast.length > 0
207 ? `${entropyRate.perPast.length} estimates · M = ${entropyRate.past}`
208 : `M = ${entropyRate.past}`}
209 </span>
210 </span>
211 </div>
212 </div>
213 {/* Settings of the measurement, not of the model — so they live with
214 the chart they change rather than in the model bar. */}
215 <div className="measure-row">
216 <label>
217 LPC order
218 <select value={lpcOrder} onChange={e => setLpcOrder(Number(e.target.value))}>
219 {LPC_ORDERS.map(o => (
220 <option key={o} value={o}>
221 {o}
222 </option>
223 ))}
224 </select>
225 </label>
226 <label>
227 block size
228 <select value={blockSize} onChange={e => setBlockSize(Number(e.target.value))}>
229 {BLOCK_SIZES.map(n => (
230 <option key={n} value={n}>
231 {n.toLocaleString()} samples
232 </option>
233 ))}
234 </select>
235 </label>
236 </div>
237 {compression.error ? (
238 <p className="card-note">Compression failed: {compression.error}</p>
239 ) : (
240 <CompressionChart
241 results={compression.results}
242 rateBits={entropyRate.mean}
243 rateSe={entropyRate.se}
244 theoryBits={theoryBits}
245 computing={compression.computing}
246 />
247 )}
248 <p className="card-note">
249 Measured on a {blockSize.toLocaleString()}-sample block of the same latent data the
250 signal view shows; sizes include everything a decoder needs (ANS symbol table, LPC
251 coefficients). Baseline is raw int16 (16 bits/sample). The two reference lines mark
252 the entropy rate R of the process — the one limit no lossless method whatsoever can
253 beat: dotted for the analytic theory, dashed for the Monte-Carlo ground truth, shaded
254 by its standard error (see the method section at the bottom).
255 What separates the methods is the model each one codes against: ANS uses the histogram
256 of whatever stream it is given, so a better prefilter is the only way it improves,
257 while the conditional-Gaussian coder codes each sample against a prediction and can
258 therefore approach R. The strip under the chart scores each coder against its own
259 model, which is a question about the coder rather than about the model.
260 </p>
261 <CopyableCommand
262 label="cross-check R from the command line:"
263 command={mcCommand(sigma, spec, sampleRateHz)}
264 />
265 </section>
267 <section className="card">
268 <h2>Quantized signal z</h2>
269 <ScrollingView kernel={kernel} sigma={sigma} sigmaY={sigmaY} />
270 <p className="card-note">
271 A window of samples from the model, drawn from a fixed latent noise sequence — changing
272 σ or the filter transforms the same underlying data, so the trace morphs rather than
273 resampling. Press play to advance through the sequence.
274 </p>
275 </section>
277 <section className="card">
278 <h2>Filter</h2>
279 <FilterViz kernel={kernel} sampleRateHz={sampleRateHz} />
280 </section>
282 <section className="card">
283 <h2>The entropy rate</h2>
284 <MethodNote />
285 </section>
286 </div>
287 )
288}