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 type { CodecResult } from './compress/codecs'
10import type { CompressRequest, CompressResponse } from './worker/compressWorker'
12const BLOCK_SIZE = 120000
13const BLOCK_SEED = 20260729
15interface CompressionState {
16 results: CodecResult[]
17 empiricalStd: number
18 computing: boolean
19 error: string | null
20}
22/** The nine codec sizes, measured in a worker on a debounced parameter set. */
23function useCompression(kernel: Float64Array, sigma: number, dither: boolean): CompressionState {
24 const [state, setState] = useState<CompressionState>({
25 results: [],
26 empiricalStd: 0,
27 computing: true,
28 error: null,
29 })
30 const workerRef = useRef<Worker | null>(null)
31 const idRef = useRef(0)
33 useEffect(() => {
34 const worker = new Worker(new URL('./worker/compressWorker.ts', import.meta.url), {
35 type: 'module',
36 })
37 worker.onmessage = (e: MessageEvent<CompressResponse>) => {
38 if (e.data.id !== idRef.current) return
39 setState({
40 results: e.data.error ? [] : e.data.results,
41 empiricalStd: e.data.empiricalStd,
42 computing: false,
43 error: e.data.error ?? null,
44 })
45 }
46 workerRef.current = worker
47 return () => {
48 worker.terminate()
49 workerRef.current = null
50 }
51 }, [])
53 useEffect(() => {
54 setState(s => ({ ...s, computing: true }))
55 const id = ++idRef.current
56 const timer = setTimeout(() => {
57 const request: CompressRequest = {
58 id,
59 kernel,
60 sigma,
61 dither,
62 blockSize: BLOCK_SIZE,
63 seed: BLOCK_SEED,
64 }
65 workerRef.current?.postMessage(request)
66 }, 250)
67 return () => clearTimeout(timer)
68 }, [kernel, sigma, dither])
70 return state
71}
73export default function App() {
74 const [sigma, setSigma] = useState(5)
75 const [sampleRateHz, setSampleRateHz] = useState(30000)
76 const [spec, setSpec] = useState(DEFAULT_SPEC)
77 const [dither, setDither] = useState(false)
79 const kernel = useMemo(() => designKernel(spec, sampleRateHz), [spec, sampleRateHz])
80 const sigmaY = useMemo(() => {
81 const filtered = sigma * kernelNorm(kernel)
82 return dither ? Math.sqrt(filtered * filtered + 1 / 12) : filtered
83 }, [kernel, sigma, dither])
84 const theoryBits = useMemo(() => theoreticalRateBits(kernel, sigma, dither), [kernel, sigma, dither])
85 const compression = useCompression(kernel, sigma, dither)
87 return (
88 <div className="app">
89 <header className="app-header">
90 <h1>Time-series compressibility</h1>
91 <p>
92 Gaussian noise → FIR filter → optional dither → round to integers. How well can the
93 integer stream be losslessly compressed, and does the spectral entropy-rate formula
94 predict the limit?
95 </p>
96 </header>
98 {/* The controls stay pinned so the parameters and the ratios they move
99 are always on screen together, whatever is scrolled to. */}
100 <section className="card control-bar">
101 <Controls
102 sigma={sigma}
103 setSigma={setSigma}
104 sampleRateHz={sampleRateHz}
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 }}
111 spec={spec}
112 setSpec={setSpec}
113 dither={dither}
114 setDither={setDither}
115 />
116 </section>
118 <section className="card">
119 <h2>Compression</h2>
120 <div className="stat-row">
121 <div className="stat">
122 <span className="label">predicted std of z</span>
123 <span className="value">
124 {sigmaY.toFixed(2)} <small>steps</small>
125 </span>
126 </div>
127 <div className="stat">
128 <span className="label">measured std of z</span>
129 <span className="value">
130 {compression.results.length > 0 ? compression.empiricalStd.toFixed(2) : '…'}{' '}
131 <small>steps</small>
132 </span>
133 </div>
134 <div className="stat">
135 <span className="label">theoretical rate R</span>
136 <span className="value">
137 {theoryBits.toFixed(2)} <small>bits/sample</small>
138 </span>
139 </div>
140 <div className="stat">
141 <span className="label">implied best ratio</span>
142 <span className="value">{theoryBits > 0 ? `${(16 / theoryBits).toFixed(2)}×` : '—'}</span>
143 </div>
144 </div>
145 {compression.error ? (
146 <p className="card-note">Compression failed: {compression.error}</p>
147 ) : (
148 <CompressionChart
149 results={compression.results}
150 theoryBits={theoryBits}
151 computing={compression.computing}
152 />
153 )}
154 <p className="card-note">
155 Measured on a {BLOCK_SIZE.toLocaleString()}-sample block of the same latent data the
156 signal view shows; sizes include everything a decoder needs (ANS symbol table, LPC
157 coefficients). Baseline is raw int16 (16 bits/sample). The dashed line is the
158 theoretical rate R from the spectral formula in the math section — approximate where
159 quantization dominates the spectrum (see the S(f) = 1 threshold on the response plot).
160 </p>
161 </section>
163 <section className="card">
164 <h2>Quantized signal z</h2>
165 <ScrollingView kernel={kernel} sigma={sigma} dither={dither} sigmaY={sigmaY} />
166 <p className="card-note">
167 A window of samples from the model, drawn from a fixed latent noise sequence — changing
168 σ, the filter, or dither transforms the same underlying data, so the trace morphs
169 rather than resampling. Press play to advance through the sequence.
170 </p>
171 </section>
173 <section className="card">
174 <h2>Filter</h2>
175 <FilterViz kernel={kernel} sampleRateHz={sampleRateHz} sigma={sigma} />
176 </section>
178 <section className="card">
179 <h2>The math</h2>
180 <MathSection />
181 </section>
182 </div>
183 )
184}