···4545- Time/Trace test cart:
4646 - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/time_trace_test.lua`
4747 - Shows elapsed ms and emits a trace once per second to the console.
4848+ - VQT test cart:
4949+ - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/vqt_test.lua --audio-device "<name-substr>"`
5050+ - Visualizes 120 bins (12 octaves) with a 2px per-bin bar chart; auto-toggles between raw and whitened views every ~3 seconds.
+9-1
docs/specs/implementation_status.md
···4242 - Lua APIs implemented: `fft/ffts/fftr/fftrs` with C-identical clamping/sum semantics.
4343 - Tests: headless unit tests cover raw-peak behavior and Lua bridging; additional clamp/sum range tests added.
44444545+- VQT (8k):
4646+ - Kernel generation for 120 semitone-spaced bins from 19.445 Hz; Hamming window; modulated and normalized over full 8192 buffer; sparse frequency-domain kernels via magnitude threshold.
4747+ - Tick-thread R2C over latest 8192 samples; per-bin sparse complex dot; magnitude scaled by 2.0.
4848+ - Unwhitened: smoothed (0.3), peak-normalized [0,1].
4949+ - Whitened: log-domain envelope (width 21), subtract, exp, mixed by alpha 0.95; smoothed and peak-normalized separately.
5050+ - Lua APIs implemented: `vqt/vqts/vqtr/vqtrs` and `vqtw/vqtsw/vqtrw/vqtrsw` with C-identical OOB behavior.
5151+ - Tests: kernel/peak sanity, Lua bridging, whitened arrays finite.
5252+4553Behavioral Notes
4654- Triangles: top-left inclusion; CCW orientation enforced internally; half-open bounding box prevents shared-edge double draws.
4755- Ellipses vs circles: fill then border may overdraw endpoints; order-dependent at axis rows (parity with TIC-80).
···5866- Audio
5967 - `sfx`, `music`; audio mixer/synth; capture ring for analysis.
6068- Analysis
6161- - `vqt/vqts/vqtr/vqtrs` and whitening variants; behavior per CLAUDE.md.
6969+ - (none for VQT); whitening completed.
6270- Sprite flags
6371 - `fget`, `fset`.
6472
+6
docs/testing/test_carts.md
···2828 - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/time_trace_test.lua`
2929 - Expected: On-screen elapsed ms text; a small marker toggles color every second; console prints `sec=<n>` lines via `trace()`.
30303131+- `tic80_rust/assets/vqt_test.lua`
3232+ - Purpose: Visualize VQT across 12 octaves (120 bins), with auto-toggle between unwhitened and whitened views.
3333+ - How to run:
3434+ - `cargo run --manifest-path tic80_rust/Cargo.toml -- tic80_rust/assets/vqt_test.lua --audio-device "<name-substr>"`
3535+ - Expected: 120 bars (2px each) filling the 240px width; octave grid lines at every 12 bins; legend shows current mode (`raw` vs `whitened`) toggled every ~3 seconds; peak bin outlined.
3636+3137Notes
3238- These carts are designed for fast feedback during local development. They complement headless tests and can reveal platform quirks (devices, timing).
3339- Keep carts small, single-purpose, and deterministic where possible.
+56
tic80_rust/assets/vqt_test.lua
···11+-- VQT visualization cart: 120 bins (12 octaves), 2px per bin across 240px
22+-- Auto-toggles between unwhitened (vqt/vqts) and whitened (vqtw/vqtsw) every few seconds.
33+44+local sw, sh = 240, 136
55+local bins = 120
66+local bw = 2 -- 2px per bin -> 120 * 2 = 240
77+local top = 12 -- top margin for labels
88+99+function BOOT()
1010+ cls(0)
1111+end
1212+1313+local function octave_color(bin)
1414+ -- Map octave (0..11) to palette indices with a pleasant ramp
1515+ local oct = math.floor(bin / 12)
1616+ local palette = {6, 7, 10, 12, 14, 9, 11, 15}
1717+ return palette[(oct % #palette) + 1]
1818+end
1919+2020+function TIC()
2121+ cls(0)
2222+2323+ -- Toggle whitened view every 3 seconds
2424+ local ms = time()
2525+ local whiten = ((ms // 3000) % 2) == 1
2626+2727+ -- Draw bars
2828+ local max_v = -1
2929+ local max_bin = 0
3030+ for i = 0, bins - 1 do
3131+ local v = whiten and vqtsw(i) or vqts(i) -- smoothed normalized
3232+ if v < 0 then v = 0 end
3333+ if v > 1 then v = 1 end
3434+ if v > max_v then max_v = v; max_bin = i end
3535+ local h = math.floor(v * (sh - (top + 8)))
3636+ local x = i * bw
3737+ local y = sh - h
3838+ local c = octave_color(i)
3939+ rect(x, y, bw - 1, h, c)
4040+ end
4141+4242+ -- Highlight peak bin
4343+ rectb(max_bin * bw, sh - math.floor(max_v * (sh - (top + 8))), bw - 1, math.floor(max_v * (sh - (top + 8))), 2)
4444+4545+ -- Draw octave grid lines every 12 bins
4646+ for o = 0, 11 do
4747+ local x = o * 12 * bw
4848+ rect(x, top, 1, sh - (top + 1), 5)
4949+ end
5050+5151+ -- Labels
5252+ local mode = whiten and "whitened" or "raw"
5353+ print("VQT (" .. mode .. ")", 2, 2, 14)
5454+ print("bins: 0..119 (12 octaves)", 2, 6, 13)
5555+end
5656+
+388
tic80_rust/src/audio/vqt.rs
···11+use realfft::{num_complex::Complex, RealFftPlanner, RealToComplex};
22+33+pub struct VqtKernel {
44+ pub indices: Vec<usize>,
55+ pub real: Vec<f32>,
66+ pub imag: Vec<f32>,
77+}
88+99+pub struct VQTState {
1010+ // Config
1111+ n: usize, // 8192
1212+ half: usize, // n/2
1313+ sample_rate: u32, // Hz (usually 44100)
1414+ bins: usize, // 120
1515+1616+ // Rolling mono buffer (last samples)
1717+ buf: Vec<f32>,
1818+ write_idx: usize,
1919+ filled: bool,
2020+2121+ // FFT planning and work buffers
2222+ r2c: std::sync::Arc<dyn RealToComplex<f32>>,
2323+ scratch: Vec<Complex<f32>>, // realfft 3.x uses Complex scratch
2424+ input: Vec<f32>,
2525+ spectrum: Vec<Complex<f32>>, // length half+1
2626+2727+ // Kernels (sparse frequency-domain)
2828+ kernels: Vec<VqtKernel>,
2929+3030+ // Output buffers
3131+ pub vqt_raw: Vec<f32>,
3232+ pub vqt_sm: Vec<f32>,
3333+ pub vqt_norm: Vec<f32>,
3434+ // Peak normalization for raw path
3535+ vqt_peak: f32,
3636+3737+ // Whitened copies
3838+ pub vqt_w_raw: Vec<f32>,
3939+ pub vqt_w_sm: Vec<f32>,
4040+ pub vqt_w_norm: Vec<f32>,
4141+ // Peak normalization for whitened path
4242+ vqt_w_peak: f32,
4343+}
4444+4545+const VQT_BINS: usize = 120;
4646+const VQT_MIN_FREQ: f32 = 19.445; // D#0/Eb0
4747+const VQT_SMOOTHING_FACTOR: f32 = 0.3;
4848+const VQT_PEAK_SMOOTH: f32 = 0.99;
4949+const VQT_PEAK_MIN: f32 = 0.0001;
5050+5151+// Whitening params
5252+const VQT_WHITEN_WIDTH: usize = 21; // odd
5353+const VQT_WHITEN_ALPHA: f32 = 0.95;
5454+const VQT_WHITEN_EPS: f32 = 1e-6;
5555+5656+impl VQTState {
5757+ pub fn new(sample_rate: u32, rolling_capacity: usize) -> Self {
5858+ let n = 8192usize;
5959+ let half = n / 2;
6060+ let mut planner = RealFftPlanner::<f32>::new();
6161+ let r2c = planner.plan_fft_forward(n);
6262+ let input = r2c.make_input_vec();
6363+ let spectrum = r2c.make_output_vec();
6464+ let scratch = r2c.make_scratch_vec();
6565+6666+ let mut s = Self {
6767+ n,
6868+ half,
6969+ sample_rate,
7070+ bins: VQT_BINS,
7171+ buf: vec![0.0; rolling_capacity.max(n)],
7272+ write_idx: 0,
7373+ filled: false,
7474+ r2c,
7575+ scratch,
7676+ input,
7777+ spectrum,
7878+ kernels: Vec::with_capacity(VQT_BINS),
7979+ vqt_raw: vec![0.0; VQT_BINS],
8080+ vqt_sm: vec![0.0; VQT_BINS],
8181+ vqt_norm: vec![0.0; VQT_BINS],
8282+ vqt_peak: VQT_PEAK_MIN,
8383+ vqt_w_raw: vec![0.0; VQT_BINS],
8484+ vqt_w_sm: vec![0.0; VQT_BINS],
8585+ vqt_w_norm: vec![0.0; VQT_BINS],
8686+ vqt_w_peak: VQT_PEAK_MIN,
8787+ };
8888+ s.generate_kernels();
8989+ s
9090+ }
9191+9292+ fn center_frequencies(&self) -> Vec<f32> {
9393+ // Semitone steps from base
9494+ (0..self.bins)
9595+ .map(|i| VQT_MIN_FREQ * (2.0f32).powf(i as f32 / 12.0))
9696+ .collect()
9797+ }
9898+9999+ fn variable_q(center: f32) -> f32 {
100100+ // Port of C schedule optimized for 8k
101101+ if center < 25.0 {
102102+ 7.4
103103+ } else if center < 30.0 {
104104+ 9.2
105105+ } else if center < 40.0 {
106106+ 11.5
107107+ } else if center < 50.0 {
108108+ 14.5
109109+ } else if center < 65.0 {
110110+ 16.0
111111+ } else if center < 160.0 {
112112+ 17.0
113113+ } else if center < 320.0 {
114114+ 15.0
115115+ } else if center < 640.0 {
116116+ 13.0
117117+ } else {
118118+ 11.0
119119+ }
120120+ }
121121+122122+ fn adaptive_threshold(center: f32) -> f32 {
123123+ let q = Self::variable_q(center);
124124+ if q > 30.0 {
125125+ 0.005
126126+ } else if q > 20.0 {
127127+ 0.01
128128+ } else {
129129+ 0.02
130130+ }
131131+ }
132132+133133+ fn generate_kernels(&mut self) {
134134+ let centers = self.center_frequencies();
135135+ self.kernels.clear();
136136+ for &f0 in ¢ers {
137137+ let q = Self::variable_q(f0);
138138+ let mut win_len = (q * (self.sample_rate as f32) / f0).round() as usize;
139139+ if win_len > self.n {
140140+ win_len = self.n;
141141+ }
142142+ if win_len < 32 {
143143+ win_len = 32;
144144+ }
145145+ // Time-domain kernel placed centrally in full N-length buffer
146146+ let mut time = vec![0.0f32; self.n];
147147+ let start = (self.n - win_len) / 2;
148148+ // Hamming window
149149+ for i in 0..win_len {
150150+ let w = 0.54
151151+ - 0.46 * (2.0 * std::f32::consts::PI * i as f32 / (win_len as f32 - 1.0)).cos();
152152+ // Modulate with cosine (real part of complex exponential); center around N/2
153153+ let idx = start + i;
154154+ let phase =
155155+ 2.0 * std::f32::consts::PI * f0 * ((idx as f32) - (self.n as f32 / 2.0))
156156+ / (self.sample_rate as f32);
157157+ time[idx] = w * phase.cos();
158158+ }
159159+ // Normalize by window length
160160+ for t in &mut time {
161161+ *t /= win_len as f32;
162162+ }
163163+ // FFT to frequency domain
164164+ let mut spec = self.r2c.make_output_vec();
165165+ // reuse input buffer
166166+ self.input[..self.n].copy_from_slice(&time);
167167+ let mut scratch = self.r2c.make_scratch_vec();
168168+ let _ = self
169169+ .r2c
170170+ .process_with_scratch(&mut self.input, &mut spec, &mut scratch);
171171+172172+ // Build sparse kernel by thresholding magnitude
173173+ let thr = Self::adaptive_threshold(f0);
174174+ let mut idxs = Vec::new();
175175+ let mut reals = Vec::new();
176176+ let mut imags = Vec::new();
177177+ for (k, c) in spec.iter().enumerate().take(self.half + 1) {
178178+ let mag = (c.re * c.re + c.im * c.im).sqrt();
179179+ if mag > thr {
180180+ idxs.push(k);
181181+ reals.push(c.re);
182182+ imags.push(c.im);
183183+ }
184184+ }
185185+ self.kernels.push(VqtKernel {
186186+ indices: idxs,
187187+ real: reals,
188188+ imag: imags,
189189+ });
190190+ }
191191+ }
192192+193193+ pub fn ingest(&mut self, sample: f32) {
194194+ self.buf[self.write_idx] = sample;
195195+ self.write_idx += 1;
196196+ if self.write_idx >= self.buf.len() {
197197+ self.write_idx = 0;
198198+ self.filled = true;
199199+ }
200200+ }
201201+202202+ fn copy_latest_window(&mut self) {
203203+ let n = self.n;
204204+ let len = self.buf.len();
205205+ let end = self.write_idx;
206206+ let start = (len + end).saturating_sub(n) % len;
207207+ if start + n <= len {
208208+ self.input[..n].copy_from_slice(&self.buf[start..start + n]);
209209+ } else {
210210+ let first = len - start;
211211+ self.input[..first].copy_from_slice(&self.buf[start..]);
212212+ self.input[first..n].copy_from_slice(&self.buf[..(n - first)]);
213213+ }
214214+ }
215215+216216+ pub fn update(&mut self) {
217217+ if !self.filled && self.write_idx < self.n {
218218+ return;
219219+ }
220220+ self.copy_latest_window();
221221+ let _ =
222222+ self.r2c
223223+ .process_with_scratch(&mut self.input, &mut self.spectrum, &mut self.scratch);
224224+225225+ // Apply kernels
226226+ for (i, ker) in self.kernels.iter().enumerate() {
227227+ let mut re = 0.0f32;
228228+ let mut im = 0.0f32;
229229+ for (j, &idx) in ker.indices.iter().enumerate() {
230230+ if idx > self.half {
231231+ continue;
232232+ }
233233+ let c = self.spectrum[idx];
234234+ let kr = ker.real[j];
235235+ let ki = ker.imag[j];
236236+ re += c.re * kr - c.im * ki;
237237+ im += c.re * ki + c.im * kr;
238238+ }
239239+ let mag = (re * re + im * im).sqrt() * 2.0;
240240+ self.vqt_raw[i] = if mag.is_finite() && mag >= 0.0 {
241241+ mag
242242+ } else {
243243+ 0.0
244244+ };
245245+ }
246246+247247+ // Smoothing and normalization (raw path)
248248+ let a = VQT_SMOOTHING_FACTOR;
249249+ let mut peak = 0.0f32;
250250+ for i in 0..self.bins {
251251+ self.vqt_sm[i] = self.vqt_sm[i] * a + self.vqt_raw[i] * (1.0 - a);
252252+ if self.vqt_sm[i] > peak {
253253+ peak = self.vqt_sm[i];
254254+ }
255255+ }
256256+ if self.vqt_peak <= 0.0 {
257257+ self.vqt_peak = VQT_PEAK_MIN;
258258+ }
259259+ if peak > self.vqt_peak {
260260+ self.vqt_peak = peak;
261261+ } else {
262262+ self.vqt_peak = self.vqt_peak * VQT_PEAK_SMOOTH + peak * (1.0 - VQT_PEAK_SMOOTH);
263263+ }
264264+ if self.vqt_peak < VQT_PEAK_MIN {
265265+ self.vqt_peak = VQT_PEAK_MIN;
266266+ }
267267+ let norm = 1.0 / self.vqt_peak;
268268+ for i in 0..self.bins {
269269+ let mut v = self.vqt_sm[i] * norm;
270270+ if v > 1.0 {
271271+ v = 1.0;
272272+ }
273273+ if !v.is_finite() {
274274+ v = 0.0;
275275+ }
276276+ self.vqt_norm[i] = v;
277277+ }
278278+279279+ // Whitening path
280280+ // log domain
281281+ let mut logm = vec![0.0f32; self.bins];
282282+ for (i, mslot) in logm.iter_mut().enumerate().take(self.bins) {
283283+ let m = if self.vqt_raw[i].is_finite() && self.vqt_raw[i] >= 0.0 {
284284+ self.vqt_raw[i]
285285+ } else {
286286+ 0.0
287287+ };
288288+ *mslot = (m + VQT_WHITEN_EPS).ln();
289289+ }
290290+ // moving average envelope
291291+ let halfw = VQT_WHITEN_WIDTH / 2;
292292+ let mut env = vec![0.0f32; self.bins];
293293+ for i in 0..self.bins {
294294+ let start = i.saturating_sub(halfw);
295295+ let end = (i + halfw).min(self.bins - 1);
296296+ let mut sum = 0.0f32;
297297+ let mut count = 0;
298298+ for val in logm.iter().take(end + 1).skip(start) {
299299+ sum += *val;
300300+ count += 1;
301301+ }
302302+ env[i] = if count > 0 {
303303+ sum / count as f32
304304+ } else {
305305+ logm[i]
306306+ };
307307+ }
308308+ // whiten and mix
309309+ for i in 0..self.bins {
310310+ let wlog = logm[i] - env[i];
311311+ let mut wamp = wlog.exp() - 1.0;
312312+ if !wamp.is_finite() || wamp < 0.0 {
313313+ wamp = 0.0;
314314+ }
315315+ let raw = self.vqt_raw[i];
316316+ let mut mixed = (1.0 - VQT_WHITEN_ALPHA) * raw + VQT_WHITEN_ALPHA * wamp;
317317+ if !mixed.is_finite() || mixed < 0.0 {
318318+ mixed = 0.0;
319319+ }
320320+ self.vqt_w_raw[i] = mixed;
321321+ }
322322+ // Smooth and normalize whitened
323323+ let mut wpeak = 0.0f32;
324324+ for i in 0..self.bins {
325325+ self.vqt_w_sm[i] = self.vqt_w_sm[i] * a + self.vqt_w_raw[i] * (1.0 - a);
326326+ if self.vqt_w_sm[i] > wpeak {
327327+ wpeak = self.vqt_w_sm[i];
328328+ }
329329+ }
330330+ if self.vqt_w_peak <= 0.0 {
331331+ self.vqt_w_peak = VQT_PEAK_MIN;
332332+ }
333333+ if wpeak > self.vqt_w_peak {
334334+ self.vqt_w_peak = wpeak;
335335+ } else {
336336+ self.vqt_w_peak = self.vqt_w_peak * VQT_PEAK_SMOOTH + wpeak * (1.0 - VQT_PEAK_SMOOTH);
337337+ }
338338+ if self.vqt_w_peak < VQT_PEAK_MIN {
339339+ self.vqt_w_peak = VQT_PEAK_MIN;
340340+ }
341341+ let wnorm = 1.0 / self.vqt_w_peak;
342342+ for i in 0..self.bins {
343343+ let mut v = self.vqt_w_sm[i] * wnorm;
344344+ if v > 1.0 {
345345+ v = 1.0;
346346+ }
347347+ if !v.is_finite() {
348348+ v = 0.0;
349349+ }
350350+ self.vqt_w_norm[i] = v;
351351+ }
352352+ }
353353+354354+ pub fn bins_count(&self) -> usize {
355355+ self.bins
356356+ }
357357+}
358358+359359+use parking_lot::RwLock;
360360+use std::sync::{Arc, OnceLock};
361361+static VQT_SHARED: OnceLock<Arc<RwLock<VQTState>>> = OnceLock::new();
362362+363363+pub fn set_global_vqt(state: Arc<RwLock<VQTState>>) {
364364+ let _ = VQT_SHARED.set(state);
365365+}
366366+367367+pub fn get_global_vqt() -> Option<&'static Arc<RwLock<VQTState>>> {
368368+ VQT_SHARED.get()
369369+}
370370+371371+pub fn query_vqt(state: &VQTState, bin: i32, smoothing: bool, whitened: bool) -> f64 {
372372+ if bin < 0 || (bin as usize) >= state.bins_count() {
373373+ return 0.0;
374374+ }
375375+ let i = bin as usize;
376376+ if !whitened {
377377+ if smoothing {
378378+ state.vqt_norm[i] as f64
379379+ } else {
380380+ // instantaneous normalized (raw divided by peak)
381381+ (state.vqt_raw[i] / state.vqt_peak) as f64
382382+ }
383383+ } else if smoothing {
384384+ state.vqt_w_norm[i] as f64
385385+ } else {
386386+ (state.vqt_w_raw[i] / state.vqt_w_peak) as f64
387387+ }
388388+}
+1
tic80_rust/src/lib.rs
···1313pub mod audio {
1414 pub mod capture;
1515 pub mod fft;
1616+ pub mod vqt;
1617}