Make LPC order and compression block size controls
5 changed files+125−51
README.mdmodified+7−3View file
@@ -7,10 +7,14 @@ steps) → FIR filter → optional additive uniform dither on [-½, ½) → roun
77 integers. The app shows the filter (convolution kernel and frequency response,
88 with cutoffs in Hz against a chosen sample rate), a window of the generated
99 integer signal (stationary by default, with a play toggle to let it stream
10-endlessly), and the measured compression of a 120,000-sample
11-block under nine methods — zlib, zstd, and an rANS entropy coder, each raw,
10+endlessly), and the measured compression of a block of the generated integers
11+under nine methods — zlib, zstd, and an rANS entropy coder, each raw,
1212 delta-coded, and LPC-residual-coded — as bits per sample and as ratio against
13-raw int16 storage.
13+raw int16 storage. The predictor order and the block size are controls, so the
14+measurement can be pushed from 10k to a million samples and LPC from order 1
15+to 128. Each prefilter group also carries a hollow bar: the order-0 entropy of
16+the stream being coded, the limit a per-sample entropy coder cannot beat, which
17+ANS misses by 1–2% (its symbol table plus its own arithmetic loss).
1418
1519 Alongside the measurements it plots a theoretical bits/sample R: quantization
1620 is modeled as an additive white noise floor on the spectrum, the one-step
src/App.tsxmodified+45−8View file
@@ -6,11 +6,13 @@ import CompressionChart from './components/CompressionChart'
66 import MathSection from './components/MathSection'
77 import { DEFAULT_SPEC, clampSpec, designKernel, kernelNorm } from './model/filters'
88 import { theoreticalRateBits } from './model/theory'
9+import { LATENT_SEED } from './model/latent'
10+import { DEFAULT_LPC_ORDER, LPC_ORDERS } from './compress/codecs'
911 import type { BoundResult, CodecResult } from './compress/codecs'
1012 import type { CompressRequest, CompressResponse } from './worker/compressWorker'
1113
12-const BLOCK_SIZE = 120000
13-const BLOCK_SEED = 20260729
14+const BLOCK_SIZES = [10000, 20000, 50000, 100000, 200000, 500000, 1000000]
15+const DEFAULT_BLOCK_SIZE = 100000
1416
1517 interface CompressionState {
1618 results: CodecResult[]
@@ -21,7 +23,13 @@ interface CompressionState {
2123 }
2224
2325 /** The nine codec sizes, measured in a worker on a debounced parameter set. */
24-function useCompression(kernel: Float64Array, sigma: number, dither: boolean): CompressionState {
26+function useCompression(
27+ kernel: Float64Array,
28+ sigma: number,
29+ dither: boolean,
30+ lpcOrder: number,
31+ blockSize: number,
32+): CompressionState {
2533 const [state, setState] = useState<CompressionState>({
2634 results: [],
2735 bounds: [],
@@ -62,13 +70,16 @@ function useCompression(kernel: Float64Array, sigma: number, dither: boolean): C
6270 kernel,
6371 sigma,
6472 dither,
65- blockSize: BLOCK_SIZE,
66- seed: BLOCK_SEED,
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,
6778 }
6879 workerRef.current?.postMessage(request)
6980 }, 250)
7081 return () => clearTimeout(timer)
71- }, [kernel, sigma, dither])
82+ }, [kernel, sigma, dither, lpcOrder, blockSize])
7283
7384 return state
7485 }
@@ -78,6 +89,8 @@ export default function App() {
7889 const [sampleRateHz, setSampleRateHz] = useState(30000)
7990 const [spec, setSpec] = useState(DEFAULT_SPEC)
8091 const [dither, setDither] = useState(false)
92+ const [lpcOrder, setLpcOrder] = useState(DEFAULT_LPC_ORDER)
93+ const [blockSize, setBlockSize] = useState(DEFAULT_BLOCK_SIZE)
8194
8295 const kernel = useMemo(() => designKernel(spec, sampleRateHz), [spec, sampleRateHz])
8396 const sigmaY = useMemo(() => {
@@ -85,7 +98,7 @@ export default function App() {
8598 return dither ? Math.sqrt(filtered * filtered + 1 / 12) : filtered
8699 }, [kernel, sigma, dither])
87100 const theoryBits = useMemo(() => theoreticalRateBits(kernel, sigma, dither), [kernel, sigma, dither])
88- const compression = useCompression(kernel, sigma, dither)
101+ const compression = useCompression(kernel, sigma, dither, lpcOrder, blockSize)
89102
90103 return (
91104 <div className="app">
@@ -145,6 +158,30 @@ export default function App() {
145158 <span className="value">{theoryBits > 0 ? `${(16 / theoryBits).toFixed(2)}×` : '—'}</span>
146159 </div>
147160 </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>
148185 {compression.error ? (
149186 <p className="card-note">Compression failed: {compression.error}</p>
150187 ) : (
@@ -156,7 +193,7 @@ export default function App() {
156193 />
157194 )}
158195 <p className="card-note">
159- Measured on a {BLOCK_SIZE.toLocaleString()}-sample block of the same latent data the
196+ Measured on a {blockSize.toLocaleString()}-sample block of the same latent data the
160197 signal view shows; sizes include everything a decoder needs (ANS symbol table, LPC
161198 coefficients). Baseline is raw int16 (16 bits/sample). The hollow bar in each group is
162199 that group's entropy limit — the order-0 entropy of the stream being coded, which no
src/app.cssmodified+24−0View file
@@ -296,6 +296,30 @@ body {
296296 color: var(--muted);
297297 }
298298
299+.measure-row {
300+ display: flex;
301+ flex-wrap: wrap;
302+ gap: 8px 20px;
303+ margin-bottom: 12px;
304+ font-size: 12px;
305+ color: var(--ink-2);
306+}
307+
308+.measure-row label {
309+ display: inline-flex;
310+ align-items: center;
311+ gap: 6px;
312+}
313+
314+.measure-row select {
315+ background: var(--surface);
316+ color: var(--ink);
317+ border: 1px solid var(--baseline);
318+ border-radius: 6px;
319+ padding: 2px 6px;
320+ font: inherit;
321+}
322+
299323 /* ---- compression chart ---- */
300324
301325 .chart-header {
src/compress/codecs.tsmodified+43−33View file
@@ -70,16 +70,20 @@ function ansSize(
7070 return encodedSize(encoded) + extraBytes
7171 }
7272
73-/** Predictor order. Going past 32 buys well under a percent on this data. */
74-const LPC_ORDER = 32
73+/** Predictor orders the UI offers; 32 is the FLAC default and ours. */
74+export const LPC_ORDERS = [1, 2, 4, 8, 16, 32, 64, 128]
75+export const DEFAULT_LPC_ORDER = 32
7576
7677 /** Fit the predictor and take the residual, with the coefficients' cost. */
77-function lpcTransform(samples: Int16Array): {
78+function lpcTransform(
79+ samples: Int16Array,
80+ order: number,
81+): {
7882 residual: Int16Array
7983 restore: (residual: Int16Array) => Int16Array
8084 extraBytes: number
8185 } {
82- const model = fitLpc(samples, LPC_ORDER)
86+ const model = fitLpc(samples, order)
8387 if (!model) throw new Error('LPC fit failed')
8488 return {
8589 residual: lpcResidual(samples, model),
@@ -124,33 +128,35 @@ export const DELTA_ANS: Codec = {
124128 size: samples => ansSize(samples, delta(samples), undelta),
125129 }
126130
127-const LPC_NOTE = `Order-${LPC_ORDER} linear prediction with integer coefficients; the size includes the coefficients`
128-
129-export const LPC_ZLIB: Codec = {
130- name: `LPC(${LPC_ORDER}) + zlib -9`,
131- note: `${LPC_NOTE}, then DEFLATE`,
132- size: samples => {
133- const { residual, extraBytes } = lpcTransform(samples)
134- return zlibSync(asBytes(residual), { level: 9 }).length + extraBytes
135- },
136-}
137-
138-export const LPC_ZSTD: Codec = {
139- name: `LPC(${LPC_ORDER}) + zstd -19`,
140- note: `${LPC_NOTE}, then Zstandard 19`,
141- size: samples => {
142- const { residual, extraBytes } = lpcTransform(samples)
143- return zstdCompress(asBytes(residual), 19).length + extraBytes
144- },
145-}
146-
147-export const LPC_ANS: Codec = {
148- name: `LPC(${LPC_ORDER}) + ANS`,
149- note: `${LPC_NOTE}, then the rANS entropy coder`,
150- size: samples => {
151- const { residual, restore, extraBytes } = lpcTransform(samples)
152- return ansSize(samples, residual, restore, extraBytes)
153- },
131+/** The three LPC codecs at a given predictor order. */
132+export function lpcCodecs(order: number): Codec[] {
133+ const note = `Order-${order} linear prediction with integer coefficients; the size includes the coefficients`
134+ return [
135+ {
136+ name: `LPC(${order}) + zlib -9`,
137+ note: `${note}, then DEFLATE`,
138+ size: samples => {
139+ const { residual, extraBytes } = lpcTransform(samples, order)
140+ return zlibSync(asBytes(residual), { level: 9 }).length + extraBytes
141+ },
142+ },
143+ {
144+ name: `LPC(${order}) + zstd -19`,
145+ note: `${note}, then Zstandard 19`,
146+ size: samples => {
147+ const { residual, extraBytes } = lpcTransform(samples, order)
148+ return zstdCompress(asBytes(residual), 19).length + extraBytes
149+ },
150+ },
151+ {
152+ name: `LPC(${order}) + ANS`,
153+ note: `${note}, then the rANS entropy coder`,
154+ size: samples => {
155+ const { residual, restore, extraBytes } = lpcTransform(samples, order)
156+ return ansSize(samples, residual, restore, extraBytes)
157+ },
158+ },
159+ ]
154160 }
155161
156162 /** The general-purpose compressors, which know nothing about the data. */
@@ -185,11 +191,15 @@ export interface BoundResult {
185191 }
186192
187193 /** The order-0 bound for each prefilter: raw samples, delta, LPC residual. */
188-export function entropyBounds(samples: Int16Array): BoundResult[] {
194+export function entropyBounds(samples: Int16Array, lpcOrder: number): BoundResult[] {
189195 const streams: { group: string; what: string; data: Int16Array }[] = [
190196 { group: 'no prefilter', what: 'the samples themselves', data: samples },
191197 { group: 'delta', what: 'the first differences', data: delta(samples) },
192- { group: 'LPC', what: `the order-${LPC_ORDER} prediction residual`, data: lpcTransform(samples).residual },
198+ {
199+ group: 'LPC',
200+ what: `the order-${lpcOrder} prediction residual`,
201+ data: lpcTransform(samples, lpcOrder).residual,
202+ },
193203 ]
194204 return streams.map(({ group, what, data }) => {
195205 const bitsPerSample = order0Entropy(data)
src/worker/compressWorker.tsmodified+6−7View file
@@ -15,9 +15,7 @@ import {
1515 DELTA_ZLIB,
1616 DELTA_ZSTD,
1717 DELTA_ANS,
18- LPC_ZLIB,
19- LPC_ZSTD,
20- LPC_ANS,
18+ lpcCodecs,
2119 type CodecResult,
2220 } from '../compress/codecs'
2321
@@ -27,6 +25,7 @@ export interface CompressRequest {
2725 sigma: number
2826 dither: boolean
2927 blockSize: number
28+ lpcOrder: number
3029 seed: number
3130 }
3231
@@ -40,12 +39,12 @@ export interface CompressResponse {
4039 error?: string
4140 }
4241
43-const CODECS = [ZLIB, ZSTD, ANS, DELTA_ZLIB, DELTA_ZSTD, DELTA_ANS, LPC_ZLIB, LPC_ZSTD, LPC_ANS]
42+const PLAIN_CODECS = [ZLIB, ZSTD, ANS, DELTA_ZLIB, DELTA_ZSTD, DELTA_ANS]
4443
4544 const post = self.postMessage as (message: CompressResponse) => void
4645
4746 self.onmessage = async (e: MessageEvent<CompressRequest>) => {
48- const { id, kernel, sigma, dither, blockSize, seed } = e.data
47+ const { id, kernel, sigma, dither, blockSize, lpcOrder, seed } = e.data
4948 try {
5049 await initCodecs()
5150 const samples = new LatentSource(seed).window(0, blockSize, kernel, sigma, dither)
@@ -60,8 +59,8 @@ self.onmessage = async (e: MessageEvent<CompressRequest>) => {
6059 const bytes = new Uint8Array(samples.buffer, 0, samples.byteLength)
6160 post({
6261 id,
63- results: compressAll(bytes, CODECS),
64- bounds: entropyBounds(samples),
62+ results: compressAll(bytes, [...PLAIN_CODECS, ...lpcCodecs(lpcOrder)]),
63+ bounds: entropyBounds(samples, lpcOrder),
6564 empiricalStd,
6665 })
6766 } catch (err) {