Ratio-first chart, quantization-floor theory formula, line-segment view, fixed latent data
9 changed files+195−136
README.mdmodified+21−15View file
@@ -12,19 +12,23 @@ block 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
1313 raw int16 storage.
1414
15-Alongside the measurements it plots the theoretical bits/sample: the entropy
16-rate of the stationary filtered Gaussian process quantized at unit step, in the
17-high-resolution limit,
15+Alongside the measurements it plots a theoretical bits/sample R: quantization
16+is modeled as an additive white noise floor on the spectrum, the one-step
17+Wiener prediction error of the resulting process comes from the
18+Szegő–Kolmogorov formula, and R is the exact entropy of that innovation
19+quantized at unit step:
1820
1921 ```
20-R = ½ log₂(2πe) + ∫₀^½ log₂ S(f) df, S(f) = σ² |H(f)|²
22+S_z(f) = σ²|H(f)|² + σ_q² σ_q² = 1/12 (1/6 with dither)
23+σ_e² = exp( 2 ∫₀^½ ln S_z(f) df )
24+R = H_Δ(σ_e) (exact quantized-Gaussian entropy)
2125 ```
2226
23-LPC + ANS should approach R — and does, where the formula is valid. Where the
24-filter's stopband pushes S(f) below one step² (see the dashed threshold on the
25-response plot), the formula under-predicts and can go negative; making that
26-breakdown visible is part of the point. The math section is a stub for the full
27-derivation.
27+Where the spectrum sits well above one step² this reduces to the classical
28+Gaussian entropy rate ½log₂(2πe) + ∫log₂S df; the noise floor keeps it finite
29+and positive where a deep stopband would send that integral to −∞. LPC + ANS
30+should approach R; probing where the approximation holds is the point. The
31+math section is a stub for the full derivation.
2832
2933 ## Run it
3034
@@ -36,16 +40,18 @@ npm run dev
3640 ## Layout
3741
3842 ```
39-src/model/ the pipeline (seeded Gaussian stream, FIR presets, dither,
40- rounding) and the entropy-rate integral
43+src/model/ the latent source (fixed seeded randomness indexed by sample
44+ position, convolved zero-phase with the kernel on demand),
45+ FIR presets, and the theoretical-rate formula
4146 src/compress/ lossless codecs run in the browser: zlib (fflate), zstd (wasm),
4247 ans.ts (a bit-identical port of simple_ans), and FLAC-style
4348 integer LPC; borrowed from entropy-quantized-linear-transform
4449 src/worker/ the codecs run off the main thread on a debounced parameter set
45-src/components/ controls, filter plots, scrolling canvas, compression chart
50+src/components/ controls, filter plots, signal canvas, compression chart
4651 ```
4752
4853 Every reported size round-trips through the decoder and includes whatever the
49-decoder needs (ANS symbol table, LPC coefficients). The compression block and
50-the scrolling display are fed by the same `Pipeline`, so what is compressed is
51-what is shown.
54+decoder needs (ANS symbol table, LPC coefficients). The signal view and the
55+compression block read the same fixed latent noise sequence — parameter changes
56+transform the same underlying data rather than resampling it, and the first
57+window shown is the start of the block that gets compressed.
src/App.tsxmodified+11−11View file
@@ -5,7 +5,7 @@ import ScrollingView from './components/ScrollingView'
55 import CompressionChart from './components/CompressionChart'
66 import MathSection from './components/MathSection'
77 import { DEFAULT_SPEC, designKernel, kernelNorm } from './model/filters'
8-import { entropyRateBits } from './model/theory'
8+import { theoreticalRateBits } from './model/theory'
99 import type { CodecResult } from './compress/codecs'
1010 import type { CompressRequest, CompressResponse } from './worker/compressWorker'
1111
@@ -81,7 +81,7 @@ export default function App() {
8181 const filtered = sigma * kernelNorm(kernel)
8282 return dither ? Math.sqrt(filtered * filtered + 1 / 12) : filtered
8383 }, [kernel, sigma, dither])
84- const theoryBits = useMemo(() => entropyRateBits(kernel, sigma), [kernel, sigma])
84+ const theoryBits = useMemo(() => theoreticalRateBits(kernel, sigma, dither), [kernel, sigma, dither])
8585 const compression = useCompression(kernel, sigma, dither)
8686
8787 return (
@@ -118,9 +118,9 @@ export default function App() {
118118 <h2>Quantized signal z</h2>
119119 <ScrollingView kernel={kernel} sigma={sigma} dither={dither} sigmaY={sigmaY} />
120120 <p className="card-note">
121- A window of samples from the model, redrawn when parameters change; press play to watch
122- it stream. Sample-and-hold rendering, so the integer staircase appears as σ approaches
123- the quantization step.
121+ A window of samples from the model, drawn from a fixed latent noise sequence — changing
122+ σ, the filter, or dither transforms the same underlying data, so the trace morphs
123+ rather than resampling. Press play to advance through the sequence.
124124 </p>
125125 </section>
126126
@@ -141,7 +141,7 @@ export default function App() {
141141 </span>
142142 </div>
143143 <div className="stat">
144- <span className="label">entropy rate R (theory)</span>
144+ <span className="label">theoretical rate R</span>
145145 <span className="value">
146146 {theoryBits.toFixed(2)} <small>bits/sample</small>
147147 </span>
@@ -161,11 +161,11 @@ export default function App() {
161161 />
162162 )}
163163 <p className="card-note">
164- Measured on a {BLOCK_SIZE.toLocaleString()}-sample block from the same model; sizes
165- include everything a decoder needs (ANS symbol table, LPC coefficients). Baseline is
166- raw int16 (16 bits/sample). The dashed line is the high-resolution entropy rate R — it
167- ignores dither and is unreliable where S(f) falls below one step² (see the response
168- plot).
164+ Measured on a {BLOCK_SIZE.toLocaleString()}-sample block of the same latent data the
165+ signal view shows; sizes include everything a decoder needs (ANS symbol table, LPC
166+ coefficients). Baseline is raw int16 (16 bits/sample). The dashed line is the
167+ theoretical rate R from the spectral formula in the math section — approximate where
168+ quantization dominates the spectrum (see the S(f) = 1 threshold on the response plot).
169169 </p>
170170 </section>
171171
src/components/CompressionChart.tsxmodified+4−4View file
@@ -41,7 +41,7 @@ export default function CompressionChart(props: {
4141 }) {
4242 const ref = useRef<HTMLDivElement>(null)
4343 const width = useWidth(ref, 720)
44- const [metric, setMetric] = useState<Metric>('bits')
44+ const [metric, setMetric] = useState<Metric>('ratio')
4545 const [tip, setTip] = useState<Tip | null>(null)
4646 const [hovered, setHovered] = useState<number | null>(null)
4747
@@ -80,12 +80,12 @@ export default function CompressionChart(props: {
8080 <div className="chart-header">
8181 <div>
8282 <div className="segmented" role="group" aria-label="metric">
83- <button className={metric === 'bits' ? 'active' : ''} onClick={() => setMetric('bits')}>
84- bits / sample
85- </button>
8683 <button className={metric === 'ratio' ? 'active' : ''} onClick={() => setMetric('ratio')}>
8784 compression ratio
8885 </button>
86+ <button className={metric === 'bits' ? 'active' : ''} onClick={() => setMetric('bits')}>
87+ bits / sample
88+ </button>
8989 </div>
9090 <span className="metric-hint">
9191 {metric === 'bits' ? 'lower is better' : 'vs int16 — higher is better'}
src/components/MathSection.tsxmodified+15−10View file
@@ -20,18 +20,23 @@ export default function MathSection() {
2020 </p>
2121 <Display tex="x_n \sim \mathcal{N}(0,\sigma^2)\ \text{i.i.d.}, \qquad y = h * x, \qquad z_n = \operatorname{round}(y_n + d_n), \quad d_n \sim \mathcal{U}[-\tfrac12,\tfrac12)\ \text{or}\ 0" />
2222 <p>
23- The dashed reference line is the entropy rate of the stationary Gaussian process y,
24- quantized at unit step, in the fine-quantization (high-resolution) limit — the ideal
25- lossless rate in bits per sample:
23+ The reference rate R treats the roundoff as an additive white noise floor on the spectrum
24+ — σ<sub>q</sub>² = 1/12 without dither, 1/6 with it (the dither is stored in the
25+ integers) — takes the one-step Wiener prediction error of the resulting process
26+ (Szegő–Kolmogorov), and charges the exact entropy of that innovation quantized at unit
27+ step:
2628 </p>
27- <Display tex="R \;=\; \tfrac12\log_2(2\pi e)\;+\;\int_0^{1/2} \log_2 S(f)\,df, \qquad S(f) = \sigma^2\,|H(f)|^2" />
29+ <Display tex="S_z(f) = \sigma^2\,|H(f)|^2 + \sigma_q^2, \qquad \sigma_e^2 = \exp\!\Big(2\!\int_0^{1/2}\!\ln S_z(f)\,df\Big), \qquad R = H_{\Delta}(\sigma_e)" />
30+ <Display tex="H_{\Delta}(s) = -\sum_{z\in\mathbb{Z}} p_z \log_2 p_z, \qquad p_z = \Phi\!\Big(\tfrac{z+\frac12}{s}\Big) - \Phi\!\Big(\tfrac{z-\frac12}{s}\Big)" />
2831 <p>
29- with f in cycles per sample. With no filter this reduces to ½ log₂(2πe σ²). The formula
30- holds when S(f) is well above one step² across the band; where the response dips toward or
31- below the quantization step — deep stopbands, small σ — the true entropy rate is larger
32- than R (and R can even go negative), and no fixed-order predictor fully whitens the
33- process. Quantifying that gap, the effect of dither, and why LPC + ANS is the right
34- yardstick is the subject of the full derivation, still to be written.
32+ with f in cycles per sample. In the fine-quantization regime (S ≫ 1 everywhere) this
33+ reduces to the classical Gaussian entropy rate ½ log₂(2πe) + ∫ log₂ S(f) df — and with
34+ no filter, to ½ log₂(2πe σ²). The noise floor keeps R finite and positive where a deep
35+ stopband pushes S(f) below one step², which is where the classical formula diverges to
36+ −∞. It is still an approximation: roundoff is not truly white, independent, or Gaussian,
37+ prediction is from the quantized past, and everything degrades when the whole signal
38+ hides inside the dead zone (σ_y ≪ 1). Quantifying that gap — and why LPC + ANS is the
39+ right yardstick — is the subject of the full derivation, still to be written.
3540 </p>
3641 </div>
3742 )
src/components/ScrollingView.tsxmodified+30−38View file
@@ -1,10 +1,9 @@
11 import { useEffect, useRef, useState } from 'react'
2-import { Pipeline } from '../model/pipeline'
2+import { LatentSource, LATENT_SEED } from '../model/latent'
33
4-/** Display samples generated per second; px per sample is fixed below. */
4+/** Display samples generated per second while playing; px per sample fixed. */
55 const RATE = 220
66 const PX_PER_SAMPLE = 2
7-const RING_SIZE = 8192
87
98 /** A nice round gridline step ≤ span/2. */
109 function niceStep(span: number): number {
@@ -15,10 +14,11 @@ function niceStep(span: number): number {
1514 }
1615
1716 /**
18- * The generated quantized signal z, drawn sample-and-hold so the integer
19- * staircase is visible once σ is small. Rendering is a canvas ring buffer fed
20- * by the same Pipeline the compression worker uses. Stationary by default — a
21- * fresh window per parameter change — with a play toggle to let it stream.
17+ * A window of the quantized signal z, drawn as connected line segments.
18+ * The underlying randomness is a fixed latent sequence indexed by absolute
19+ * sample position — parameter changes re-render the same window of latent
20+ * data (no resampling), so the trace morphs smoothly. Stationary by default;
21+ * the play toggle advances the window through the latent sequence.
2222 */
2323 export default function ScrollingView(props: {
2424 kernel: Float64Array
@@ -28,31 +28,20 @@ export default function ScrollingView(props: {
2828 sigmaY: number
2929 }) {
3030 const canvasRef = useRef<HTMLCanvasElement>(null)
31- const seedRef = useRef(1)
3231 const [playing, setPlaying] = useState(false)
3332 const playingRef = useRef(playing)
3433 playingRef.current = playing
34+ // Latent noise and window position survive parameter changes.
35+ const latentRef = useRef<LatentSource | null>(null)
36+ if (!latentRef.current) latentRef.current = new LatentSource(LATENT_SEED)
37+ const posRef = useRef(0)
3538
3639 useEffect(() => {
3740 const canvas = canvasRef.current
3841 if (!canvas) return
3942 const ctx = canvas.getContext('2d')
4043 if (!ctx) return
41-
42- const pipeline = new Pipeline(props.kernel, props.sigma, props.dither, seedRef.current++)
43- const ring = new Float32Array(RING_SIZE)
44- let head = 0
45- let filled = 0
46- const push = (samples: Int16Array) => {
47- for (let i = 0; i < samples.length; i++) {
48- ring[head] = samples[i]
49- head = (head + 1) % RING_SIZE
50- }
51- filled = Math.min(RING_SIZE, filled + samples.length)
52- }
53-
54- // Start with a full screen of history so the view is never empty.
55- push(pipeline.next(2048))
44+ const latent = latentRef.current!
5645
5746 const scale = Math.max(4 * props.sigmaY, 3.5)
5847 const gridStep = niceStep(scale)
@@ -76,7 +65,7 @@ export default function ScrollingView(props: {
7665 carry += dt * RATE
7766 const n = Math.floor(carry)
7867 carry -= n
79- if (n > 0) push(pipeline.next(n))
68+ posRef.current += n
8069 } else {
8170 carry = 0
8271 }
@@ -91,32 +80,39 @@ export default function ScrollingView(props: {
9180 }
9281 ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
9382
83+ const visible = Math.floor(w / PX_PER_SAMPLE)
84+ // The window ends at posRef and never reaches before index 0, so the
85+ // first thing shown is the start of the compression block.
86+ if (posRef.current < visible) posRef.current = visible
87+ const win = latent.window(posRef.current - visible, visible, props.kernel, props.sigma, props.dither)
88+
9489 const surface = styles.getPropertyValue('--surface')
9590 ctx.fillStyle = surface
9691 ctx.fillRect(0, 0, w, h)
9792
9893 const yOf = (v: number) => h / 2 - (v / scale) * (h / 2 - 12)
9994
100- ctx.strokeStyle = styles.getPropertyValue('--grid')
10195 ctx.lineWidth = 1
102- ctx.fillStyle = styles.getPropertyValue('--muted')
10396 ctx.font = '11px system-ui, sans-serif'
10497 ctx.textAlign = 'left'
10598 for (let g = -2; g <= 2; g++) {
10699 const v = g * gridStep
107100 if (Math.abs(v) > scale) continue
108101 const y = Math.round(yOf(v)) + 0.5
109- ctx.beginPath()
110- ctx.moveTo(0, y)
111- ctx.lineTo(w, y)
112- if (g !== 0) ctx.stroke()
102+ if (g !== 0) {
103+ ctx.strokeStyle = styles.getPropertyValue('--grid')
104+ ctx.beginPath()
105+ ctx.moveTo(0, y)
106+ ctx.lineTo(w, y)
107+ ctx.stroke()
108+ }
113109 // A surface-colored halo keeps the label readable over the trace.
110+ const label = `${v > 0 ? '+' : ''}${+v.toPrecision(3)}`
111+ ctx.fillStyle = styles.getPropertyValue('--muted')
114112 ctx.strokeStyle = surface
115113 ctx.lineWidth = 3
116- const label = `${v > 0 ? '+' : ''}${+v.toPrecision(3)}`
117114 ctx.strokeText(label, 6, y - 4)
118115 ctx.fillText(label, 6, y - 4)
119- ctx.strokeStyle = styles.getPropertyValue('--grid')
120116 ctx.lineWidth = 1
121117 }
122118 const zeroY = Math.round(yOf(0)) + 0.5
@@ -126,19 +122,15 @@ export default function ScrollingView(props: {
126122 ctx.lineTo(w, zeroY)
127123 ctx.stroke()
128124
129- const visible = Math.min(filled, Math.floor(w / PX_PER_SAMPLE))
130125 ctx.strokeStyle = styles.getPropertyValue('--series-1')
131126 ctx.lineWidth = 2
132127 ctx.lineJoin = 'round'
133128 ctx.beginPath()
134129 for (let i = 0; i < visible; i++) {
135- const idx = (head - visible + i + RING_SIZE) % RING_SIZE
136- const x = w - (visible - i) * PX_PER_SAMPLE
137- const y = yOf(ring[idx])
138- // Sample-and-hold: horizontal run at each value, vertical jump between.
130+ const x = w - (visible - i) * PX_PER_SAMPLE + PX_PER_SAMPLE / 2
131+ const y = yOf(win[i])
139132 if (i === 0) ctx.moveTo(x, y)
140133 else ctx.lineTo(x, y)
141- ctx.lineTo(x + PX_PER_SAMPLE, y)
142134 }
143135 ctx.stroke()
144136 }
src/model/latent.tsadded+62−0View file
@@ -0,0 +1,62 @@
1+/**
2+ * A fixed latent randomness underlying everything: standard normals (and
3+ * dither uniforms) indexed by absolute sample position. The pipeline
4+ * x → h*x → (+dither) → round is evaluated on demand against these indices,
5+ * so changing σ, the filter, or dither transforms the *same* underlying data
6+ * — the display morphs smoothly instead of resampling — and the compression
7+ * block (indices 0…N) shares its randomness with the displayed window.
8+ */
9+import { GaussianStream } from './random'
10+
11+export const LATENT_SEED = 20260729
12+
13+export class LatentSource {
14+ private xs: number[] = []
15+ private ds: number[] = []
16+ private xStream: GaussianStream
17+ private dStream: GaussianStream
18+
19+ constructor(seed: number) {
20+ this.xStream = new GaussianStream(seed)
21+ this.dStream = new GaussianStream((seed ^ 0x9e3779b9) >>> 0)
22+ }
23+
24+ private ensure(n: number) {
25+ while (this.xs.length <= n) {
26+ this.xs.push(this.xStream.normal())
27+ this.ds.push(this.dStream.uniformCentered())
28+ }
29+ }
30+
31+ /**
32+ * Quantized samples for absolute indices [start, start + count). The kernel
33+ * is applied zero-phase (centered on its midpoint), so changing its length
34+ * does not shift features along the time axis. Latent indices before 0 read
35+ * as zero input.
36+ */
37+ window(
38+ start: number,
39+ count: number,
40+ kernel: Float64Array,
41+ sigma: number,
42+ dither: boolean,
43+ ): Int16Array {
44+ const L = kernel.length
45+ const mid = (L - 1) >> 1
46+ this.ensure(start + count - 1 + mid)
47+ const { xs, ds } = this
48+ const out = new Int16Array(count)
49+ for (let j = 0; j < count; j++) {
50+ const n = start + j
51+ let y = 0
52+ for (let k = 0; k < L; k++) {
53+ const idx = n - k + mid
54+ if (idx >= 0) y += kernel[k] * xs[idx]
55+ }
56+ y *= sigma
57+ if (dither) y += ds[n]
58+ out[j] = Math.max(-32768, Math.min(32767, Math.round(y)))
59+ }
60+ return out
61+ }
62+}
src/model/pipeline.tsdeleted+0−42View file
@@ -1,42 +0,0 @@
1-/**
2- * The generating model: x ~ N(0, σ²) i.i.d. → FIR filter → optional additive
3- * uniform dither on [-1/2, 1/2) → round to integers (the quantization step is
4- * the unit, so σ is measured in steps).
5- *
6- * A single streaming implementation feeds both the scrolling display and the
7- * compression block, so what is compressed is exactly what is shown.
8- */
9-import { GaussianStream } from './random'
10-
11-export class Pipeline {
12- private rng: GaussianStream
13- /** Ring of the last kernel-length inputs; index 0 is the newest. */
14- private history: Float64Array
15- private pos = 0
16-
17- constructor(
18- private kernel: Float64Array,
19- private sigma: number,
20- private dither: boolean,
21- seed: number,
22- ) {
23- this.rng = new GaussianStream(seed)
24- this.history = new Float64Array(kernel.length)
25- }
26-
27- /** Generate the next n quantized samples, clamped into int16 range. */
28- next(n: number): Int16Array {
29- const { kernel, history } = this
30- const L = kernel.length
31- const out = new Int16Array(n)
32- for (let j = 0; j < n; j++) {
33- this.pos = (this.pos + L - 1) % L
34- history[this.pos] = this.sigma * this.rng.normal()
35- let y = 0
36- for (let k = 0; k < L; k++) y += kernel[k] * history[(this.pos + k) % L]
37- if (this.dither) y += this.rng.uniformCentered()
38- out[j] = Math.max(-32768, Math.min(32767, Math.round(y)))
39- }
40- return out
41- }
42-}
src/model/theory.tsmodified+50−14View file
@@ -1,24 +1,60 @@
11 /**
2- * The theoretical bits/sample: the entropy rate of the stationary Gaussian
3- * process y = h * x with x ~ N(0, σ²) i.i.d., quantized at unit step. In the
4- * fine-quantization (high-resolution) regime the discrete entropy rate
5- * approaches the differential entropy rate (Kolmogorov):
2+ * The theoretical bits/sample for the quantized filtered-Gaussian process.
63 *
7- * R = ½ log₂(2πe) + ∫₀^{1/2} log₂ S(f) df, S(f) = σ² |H(f)|²
4+ * The pure high-resolution entropy rate ½log₂(2πe) + ∫log₂ S(f) df diverges
5+ * to -∞ wherever the spectrum falls far below the quantization step, so it is
6+ * useless for filters with deep stopbands. Instead, model the roundoff as an
7+ * additive white noise floor σ_q² (1/12 without dither; 1/6 with, since the
8+ * dither itself is carried into the stored integers):
89 *
9- * with f in cycles/sample. With no filter this reduces to ½ log₂(2πe σ²).
10- * The formula ignores dither and degrades where S(f) falls to the order of
11- * the quantization step or below — exactly the regime the app probes.
10+ * S_z(f) = S(f) + σ_q², S(f) = σ² |H(f)|²
11+ *
12+ * The one-step Wiener prediction error of that process (Szegő/Kolmogorov)
13+ *
14+ * σ_e² = exp( 2 ∫₀^{1/2} ln S_z(f) df )
15+ *
16+ * is what an ideal predictor leaves behind; the rate is the exact entropy of
17+ * that innovation quantized at unit step, R = H_Δ(σ_e). Where S ≫ 1 this
18+ * reduces to the classical ½log₂(2πe σ_e²); in the coarse regime it stays
19+ * positive and finite. It remains an approximation — roundoff is not truly
20+ * white or independent — and testing it against LPC+ANS is the app's point.
1221 */
1322
1423 const INTEGRATION_POINTS = 8192
1524
16-export function entropyRateBits(kernel: Float64Array, sigma: number): number {
25+/**
26+ * Exact entropy (bits) of round(N(0, s²)) on the unit lattice. Per-bin
27+ * probabilities by Simpson integration of the density, so no erf is needed
28+ * and the tail keeps relative accuracy.
29+ */
30+export function quantizedGaussianEntropy(s: number): number {
31+ if (s <= 0.02) return 0
32+ const zMax = Math.ceil(8 * s + 4)
33+ // Enough points that a bin spans a few per standard deviation even when the
34+ // bin is wide compared to the distribution.
35+ const m = Math.min(401, Math.max(9, 2 * Math.ceil(3 / s) + 9)) | 1
36+ const h = 1 / (m - 1)
37+ const density = (u: number) => Math.exp((-u * u) / (2 * s * s)) / (Math.sqrt(2 * Math.PI) * s)
38+ let sumH = 0
39+ let total = 0
40+ for (let z = -zMax; z <= zMax; z++) {
41+ let acc = density(z - 0.5) + density(z + 0.5)
42+ for (let i = 1; i < m - 1; i++) acc += (i % 2 === 1 ? 4 : 2) * density(z - 0.5 + i * h)
43+ const p = (acc * h) / 3
44+ if (p > 0) {
45+ sumH -= p * Math.log2(p)
46+ total += p
47+ }
48+ }
49+ // Renormalize away the residual quadrature/truncation mass.
50+ return sumH / total + Math.log2(total)
51+}
52+
53+export function theoreticalRateBits(kernel: Float64Array, sigma: number, dither: boolean): number {
54+ const noiseVar = dither ? 1 / 6 : 1 / 12
1755 const L = kernel.length
1856 let integral = 0
1957 for (let k = 0; k < INTEGRATION_POINTS; k++) {
20- // Midpoint rule keeps f = 0 (where a bandpass H vanishes) off the grid;
21- // the log singularity at isolated zeros is integrable.
2258 const f = (0.5 * (k + 0.5)) / INTEGRATION_POINTS
2359 let re = 0
2460 let im = 0
@@ -26,9 +62,9 @@ export function entropyRateBits(kernel: Float64Array, sigma: number): number {
2662 re += kernel[i] * Math.cos(2 * Math.PI * f * i)
2763 im -= kernel[i] * Math.sin(2 * Math.PI * f * i)
2864 }
29- const S = sigma * sigma * (re * re + im * im)
30- integral += Math.log2(Math.max(S, 1e-300))
65+ integral += Math.log2(sigma * sigma * (re * re + im * im) + noiseVar)
3166 }
3267 integral *= 0.5 / INTEGRATION_POINTS
33- return 0.5 * Math.log2(2 * Math.PI * Math.E) + integral
68+ // σ_e² = 2^(2·integral), so σ_e = 2^integral.
69+ return quantizedGaussianEntropy(2 ** integral)
3470 }
src/worker/compressWorker.tsmodified+2−2View file
@@ -3,7 +3,7 @@
33 * stutters while zstd -19 or the LPC fit runs. One message in (the model),
44 * one message out (the nine codec results).
55 */
6-import { Pipeline } from '../model/pipeline'
6+import { LatentSource } from '../model/latent'
77 import {
88 initCodecs,
99 compressAll,
@@ -44,7 +44,7 @@ self.onmessage = async (e: MessageEvent<CompressRequest>) => {
4444 const { id, kernel, sigma, dither, blockSize, seed } = e.data
4545 try {
4646 await initCodecs()
47- const samples = new Pipeline(kernel, sigma, dither, seed).next(blockSize)
47+ const samples = new LatentSource(seed).window(0, blockSize, kernel, sigma, dither)
4848 let sum = 0
4949 let sumSq = 0
5050 for (let i = 0; i < samples.length; i++) {