import { useEffect, useMemo, useRef, useState } from 'react' import CopyableCommand from './components/CopyableCommand' import Controls from './components/Controls' import FilterViz from './components/FilterViz' import ScrollingView from './components/ScrollingView' import CompressionChart from './components/CompressionChart' import MethodNote from './components/MethodNote' import { useEntropyRate } from './components/useEntropyRate' import { predictEntropyRate } from './entropy' import { DEFAULT_SPEC, clampSpec, designKernel, kernelNorm } from './model/filters' import { LATENT_SEED } from './model/latent' import { DEFAULT_LPC_ORDER, LPC_ORDERS } from './compress/codecs' import type { CodecResult } from './compress/codecs' import type { CompressRequest, CompressResponse } from './worker/compressWorker' const BLOCK_SIZES = [10000, 20000, 50000, 100000, 200000, 500000, 1000000] const DEFAULT_BLOCK_SIZE = 100000 interface CompressionState { results: CodecResult[] empiricalStd: number computing: boolean error: string | null } /** The ten codec sizes, measured in a worker on a debounced parameter set. */ function useCompression( kernel: Float64Array, sigma: number, lpcOrder: number, blockSize: number, ): CompressionState { const [state, setState] = useState({ results: [], empiricalStd: 0, computing: true, error: null, }) const workerRef = useRef(null) const idRef = useRef(0) useEffect(() => { const worker = new Worker(new URL('./worker/compressWorker.ts', import.meta.url), { type: 'module', }) worker.onmessage = (e: MessageEvent) => { if (e.data.id !== idRef.current) return setState({ results: e.data.error ? [] : e.data.results, empiricalStd: e.data.empiricalStd, computing: false, error: e.data.error ?? null, }) } workerRef.current = worker return () => { worker.terminate() workerRef.current = null } }, []) useEffect(() => { setState(s => ({ ...s, computing: true })) const id = ++idRef.current const timer = setTimeout(() => { const request: CompressRequest = { id, kernel, sigma, blockSize, lpcOrder, // The same seed the signal view draws from, so the block really is // the data on screen. seed: LATENT_SEED, } workerRef.current?.postMessage(request) }, 250) return () => clearTimeout(timer) }, [kernel, sigma, lpcOrder, blockSize]) return state } /** * The terminal command that estimates the entropy rate R at the current * settings, using the unbiased Monte-Carlo estimator from the companion * timeseries-entropy package. */ function mcCommand(sigma: number, spec: ReturnType, rate: number): string { const parts = [ 'uvx --from git+https://github.com/concept-collection/timeseries-entropy', 'timeseries-entropy', `--sigma ${sigma}`, ] switch (spec.family) { case 'none': parts.push('--filter none') break case 'movingAverage': parts.push('--filter moving-average', `--width ${spec.width}`) break case 'lowpass': parts.push('--filter lowpass', `--high ${spec.highHz}`, `--taps ${spec.taps}`, `--rate ${rate}`) break case 'bandpass': parts.push( '--filter bandpass', `--low ${spec.lowHz}`, `--high ${spec.highHz}`, `--taps ${spec.taps}`, `--rate ${rate}`, ) break case 'firstDifference': parts.push('--filter first-difference') break } return parts.join(' ') } export default function App() { const [sigma, setSigma] = useState(5) const [sampleRateHz, setSampleRateHz] = useState(30000) const [spec, setSpec] = useState(DEFAULT_SPEC) const [lpcOrder, setLpcOrder] = useState(DEFAULT_LPC_ORDER) const [blockSize, setBlockSize] = useState(DEFAULT_BLOCK_SIZE) const kernel = useMemo(() => designKernel(spec, sampleRateHz), [spec, sampleRateHz]) const sigmaY = useMemo(() => sigma * kernelNorm(kernel), [kernel, sigma]) const compression = useCompression(kernel, sigma, lpcOrder, blockSize) const entropyRate = useEntropyRate(kernel, sigma) const theoryBits = useMemo(() => predictEntropyRate(kernel, sigma), [kernel, sigma]) return (

Time-series compressibility

Gaussian noise → FIR filter → round to integers. How well can the integer stream be losslessly compressed, and how close do practical codecs get to the entropy rate of the process?

{/* The controls stay pinned so the parameters and the ratios they move are always on screen together, whatever is scrolled to. */}
{ // Band edges are absolute, so a new rate can push them past // Nyquist; re-snap the spec so sliders and kernel stay in step. setSampleRateHz(rate) setSpec(s => clampSpec(s, rate)) }} spec={spec} setSpec={setSpec} />

Compression

{/* Two readouts of the same number, each keyed to its chart line by a sample of that line's own stroke. */}
entropy rate R — analytic theory {theoryBits.toFixed(2)} bits/sample {theoryBits > 0 ? `best possible ratio ${(16 / theoryBits).toFixed(2)}×` : '—'}
entropy rate R — Monte-Carlo ground truth {entropyRate.mean !== null ? entropyRate.mean.toFixed(2) : '—'} {entropyRate.se !== null && ± {entropyRate.se.toFixed(2)}}{' '} bits/sample {entropyRate.mean !== null && entropyRate.mean > 0 ? `best possible ratio ${(16 / entropyRate.mean).toFixed(2)}×` : 'run the estimate to check the theory'} {/* The estimate lives with its readout: start, watch it refine, stop; a model change resets it. */} {/* Each independent past contributes one unbiased estimate; the readout is their average, so that is the word used. */} {entropyRate.running ? `${entropyRate.perPast.length} estimates · ${entropyRate.progress ?? 'starting…'}` : entropyRate.perPast.length > 0 ? `${entropyRate.perPast.length} estimates · M = ${entropyRate.past}` : `M = ${entropyRate.past}`}
{/* Settings of the measurement, not of the model — so they live with the chart they change rather than in the model bar. */}
{compression.error ? (

Compression failed: {compression.error}

) : ( )}

Measured on a {blockSize.toLocaleString()}-sample block of the same latent data the signal view shows; sizes include everything a decoder needs (ANS symbol table, LPC coefficients). Baseline is raw int16 (16 bits/sample). The two reference lines mark the entropy rate R of the process — the one limit no lossless method whatsoever can beat: dotted for the analytic theory, dashed for the Monte-Carlo ground truth, shaded by its standard error (see the method section at the bottom). What separates the methods is the model each one codes against: ANS uses the histogram of whatever stream it is given, so a better prefilter is the only way it improves, while the conditional-Gaussian coder codes each sample against a prediction and can therefore approach R. The strip under the chart scores each coder against its own model, which is a question about the coder rather than about the model.

Quantized signal z

A window of samples from the model, drawn from a fixed latent noise sequence — changing σ or the filter transforms the same underlying data, so the trace morphs rather than resampling. Press play to advance through the sequence.

Filter

The entropy rate

) }