1import { useEffect, useMemo, useRef, useState } from 'react'
2import Controls from './components/Controls'
3import FilterViz from './components/FilterViz'
4import ScrollingView from './components/ScrollingView'
5import CompressionChart from './components/CompressionChart'
6import MathSection from './components/MathSection'
7import { DEFAULT_SPEC, clampSpec, designKernel, kernelNorm } from './model/filters'
8import { theoreticalRateBits } from './model/theory'
9import { LATENT_SEED } from './model/latent'
10import { DEFAULT_LPC_ORDER, LPC_ORDERS } from './compress/codecs'
11import type { BoundResult, CodecResult } from './compress/codecs'
12import type { CompressRequest, CompressResponse } from './worker/compressWorker'
14const BLOCK_SIZES = [10000, 20000, 50000, 100000, 200000, 500000, 1000000]
15const DEFAULT_BLOCK_SIZE = 100000
17interface CompressionState {
18 results: CodecResult[]
19 bounds: BoundResult[]
20 empiricalStd: number
21 computing: boolean
22 error: string | null
23}
25/** The nine codec sizes, measured in a worker on a debounced parameter set. */
26function useCompression(
27 kernel: Float64Array,
28 sigma: number,
29 dither: boolean,
30 lpcOrder: number,
31 blockSize: number,
32): CompressionState {
33 const [state, setState] = useState<CompressionState>({
34 results: [],
35 bounds: [],
36 empiricalStd: 0,
37 computing: true,
38 error: null,
39 })
40 const workerRef = useRef<Worker | null>(null)
41 const idRef = useRef(0)
43 useEffect(() => {
44 const worker = new Worker(new URL('./worker/compressWorker.ts', import.meta.url), {
45 type: 'module',
46 })
47 worker.onmessage = (e: MessageEvent<CompressResponse>) => {
48 if (e.data.id !== idRef.current) return
49 setState({
50 results: e.data.error ? [] : e.data.results,
51 bounds: e.data.error ? [] : e.data.bounds,
52 empiricalStd: e.data.empiricalStd,
53 computing: false,
54 error: e.data.error ?? null,
55 })
56 }
57 workerRef.current = worker
58 return () => {
59 worker.terminate()
60 workerRef.current = null
61 }
62 }, [])
64 useEffect(() => {
65 setState(s => ({ ...s, computing: true }))
66 const id = ++idRef.current
67 const timer = setTimeout(() => {
68 const request: CompressRequest = {
69 id,
70 kernel,
71 sigma,
72 dither,
73 blockSize,
74 lpcOrder,
75 // The same seed the signal view draws from, so the block really is
76 // the data on screen.
77 seed: LATENT_SEED,
78 }
79 workerRef.current?.postMessage(request)
80 }, 250)
81 return () => clearTimeout(timer)
82 }, [kernel, sigma, dither, lpcOrder, blockSize])
84 return state
85}
87export default function App() {
88 const [sigma, setSigma] = useState(5)
89 const [sampleRateHz, setSampleRateHz] = useState(30000)
90 const [spec, setSpec] = useState(DEFAULT_SPEC)
91 const [dither, setDither] = useState(false)
92 const [lpcOrder, setLpcOrder] = useState(DEFAULT_LPC_ORDER)
93 const [blockSize, setBlockSize] = useState(DEFAULT_BLOCK_SIZE)
95 const kernel = useMemo(() => designKernel(spec, sampleRateHz), [spec, sampleRateHz])
96 const sigmaY = useMemo(() => {
97 const filtered = sigma * kernelNorm(kernel)
98 return dither ? Math.sqrt(filtered * filtered + 1 / 12) : filtered
99 }, [kernel, sigma, dither])
100 const theoryBits = useMemo(() => theoreticalRateBits(kernel, sigma, dither), [kernel, sigma, dither])
101 const compression = useCompression(kernel, sigma, dither, lpcOrder, blockSize)
103 return (
104 <div className="app">
105 <header className="app-header">
106 <h1>Time-series compressibility</h1>
107 <p>
108 Gaussian noise → FIR filter → optional dither → round to integers. How well can the
109 integer stream be losslessly compressed, and does the spectral entropy-rate formula
110 predict the limit?
111 </p>
112 </header>
114 {/* The controls stay pinned so the parameters and the ratios they move
115 are always on screen together, whatever is scrolled to. */}
116 <section className="card control-bar">
117 <Controls
118 sigma={sigma}
119 setSigma={setSigma}
120 sampleRateHz={sampleRateHz}
121 setSampleRateHz={rate => {
122 // Band edges are absolute, so a new rate can push them past
123 // Nyquist; re-snap the spec so sliders and kernel stay in step.
124 setSampleRateHz(rate)
125 setSpec(s => clampSpec(s, rate))
126 }}
127 spec={spec}
128 setSpec={setSpec}
129 dither={dither}
130 setDither={setDither}
131 />
132 </section>
134 <section className="card">
135 <h2>Compression</h2>
136 <div className="stat-row">
137 <div className="stat">
138 <span className="label">predicted std of z</span>
139 <span className="value">
140 {sigmaY.toFixed(2)} <small>steps</small>
141 </span>
142 </div>
143 <div className="stat">
144 <span className="label">measured std of z</span>
145 <span className="value">
146 {compression.results.length > 0 ? compression.empiricalStd.toFixed(2) : '…'}{' '}
147 <small>steps</small>
148 </span>
149 </div>
150 <div className="stat">
151 <span className="label">theoretical rate R</span>
152 <span className="value">
153 {theoryBits.toFixed(2)} <small>bits/sample</small>
154 </span>
155 </div>
156 <div className="stat">
157 <span className="label">implied best ratio</span>
158 <span className="value">{theoryBits > 0 ? `${(16 / theoryBits).toFixed(2)}×` : '—'}</span>
159 </div>
160 </div>
161 {/* Settings of the measurement, not of the model — so they live with
162 the chart they change rather than in the model bar. */}
163 <div className="measure-row">
164 <label>
165 LPC order
166 <select value={lpcOrder} onChange={e => setLpcOrder(Number(e.target.value))}>
167 {LPC_ORDERS.map(o => (
168 <option key={o} value={o}>
169 {o}
170 </option>
171 ))}
172 </select>
173 </label>
174 <label>
175 block size
176 <select value={blockSize} onChange={e => setBlockSize(Number(e.target.value))}>
177 {BLOCK_SIZES.map(n => (
178 <option key={n} value={n}>
179 {n.toLocaleString()} samples
180 </option>
181 ))}
182 </select>
183 </label>
184 </div>
185 {compression.error ? (
186 <p className="card-note">Compression failed: {compression.error}</p>
187 ) : (
188 <CompressionChart
189 results={compression.results}
190 bounds={compression.bounds}
191 theoryBits={theoryBits}
192 computing={compression.computing}
193 />
194 )}
195 <p className="card-note">
196 Measured on a {blockSize.toLocaleString()}-sample block of the same latent data the
197 signal view shows; sizes include everything a decoder needs (ANS symbol table, LPC
198 coefficients). Baseline is raw int16 (16 bits/sample). The hollow bar in each group is
199 that group's entropy limit — the order-0 entropy of the stream being coded, which no
200 per-sample entropy coder can beat and ANS falls short of by its symbol table plus its
201 own arithmetic loss. The dashed line is the theoretical rate R from the spectral
202 formula in the math section — approximate where quantization dominates the spectrum
203 (see the S(f) = 1 threshold on the response plot).
204 </p>
205 </section>
207 <section className="card">
208 <h2>Quantized signal z</h2>
209 <ScrollingView kernel={kernel} sigma={sigma} dither={dither} sigmaY={sigmaY} />
210 <p className="card-note">
211 A window of samples from the model, drawn from a fixed latent noise sequence — changing
212 σ, the filter, or dither transforms the same underlying data, so the trace morphs
213 rather than resampling. Press play to advance through the sequence.
214 </p>
215 </section>
217 <section className="card">
218 <h2>Filter</h2>
219 <FilterViz kernel={kernel} sampleRateHz={sampleRateHz} sigma={sigma} />
220 </section>
222 <section className="card">
223 <h2>The math</h2>
224 <MathSection />
225 </section>
226 </div>
227 )
228}