Skip to main content

max / audiofiles

Fix selection clamp off-by-one and AIFF dither determinism (ultra-fuzz run 3) NF-2 (UI): refresh_contents clamped the selection and scroll target against contents.len(), but both are display-space indices that include the ".." parent row when a subfolder is open. On the bulk-op refresh paths (which don't clear the selection first) this dropped a selection on the last sample and shifted the cursor one row up — and the newly-added refresh_selected_detail then rendered the wrong sample's waveform. Clamp against visible_len() instead. Regression test covers the last-row-in-subfolder case. NF-1 (DSP): the 16-bit AIFF encoder still seeded its dither from the buffer pointer XOR length, so AIFF exports were nondeterministic and inconsistent with the reproducibility the WAV path now guarantees. Use the shared DITHER_SEED. Reproducibility test added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 21:24 UTC
Commit: 035db7b1313d6289ff7bdd59b40f7214bcdf9415
Parent: db956d5
3 files changed, +75 insertions, -3 deletions
@@ -64,7 +64,14 @@ impl BrowserState {
64 64 // Contents may have shrunk (delete/purge/search). Pull the cursor and
65 65 // any pending scroll target back inside the new bounds so the highlight
66 66 // can't dangle off the end and disagree with the detail panel.
67 - let len = self.contents.len();
67 + //
68 + // Both the selection cursor and scroll_to_row are display-space indices
69 + // (they include the ".." parent row when a subfolder is open), so they
70 + // must be clamped against visible_len(), NOT contents.len(). Using the
71 + // content length clamps one row short and drops a selection on the last
72 + // sample — which the refresh_selected_detail below would then render as
73 + // the wrong sample's waveform.
74 + let len = self.visible_len();
68 75 self.selection.clamp_to(len);
69 76 if let Some(row) = self.scroll_to_row
70 77 && row >= len {
@@ -1267,6 +1267,50 @@ mod navigation_and_filter {
1267 1267 }
1268 1268
1269 1269 #[test]
1270 + fn refresh_contents_keeps_last_sample_selected_in_subfolder() {
1271 + // Regression (NF-2): refresh_contents must clamp the display-space cursor
1272 + // against visible_len(), not contents.len(). Inside a subfolder the ".."
1273 + // parent row shifts every sample index up by one, so a selection on the
1274 + // last sample sits at display index contents.len(). Clamping to
1275 + // contents.len() (= one short) dropped it and left the detail panel on
1276 + // the wrong sample.
1277 + let (mut state, _dir) = make_state();
1278 + add_directory(&state, "Drums");
1279 + state.refresh_contents();
1280 +
1281 + // Enter the subfolder so the ".." parent entry is present.
1282 + state.selection.set_single(0);
1283 + state.enter_directory();
1284 +
1285 + // Add samples inside the subfolder.
1286 + add_sample_to_vfs(&state, "h1", "kick.wav");
1287 + add_sample_to_vfs(&state, "h2", "snare.wav");
1288 + add_sample_to_vfs(&state, "h3", "hihat.wav");
1289 + state.refresh_contents();
1290 +
1291 + // ".." + 3 samples = 4 display rows; the last sample is display index 3.
1292 + // (Contents are sorted, so capture the last row's name rather than
1293 + // assuming insertion order.)
1294 + assert_eq!(state.visible_len(), 4);
1295 + let last = state.visible_len() - 1;
1296 + state.selection.set_single(last);
1297 + let before = state.selected_node().map(|n| n.node.name.clone());
1298 + assert!(before.is_some(), "a real sample, not the parent entry, must be selected");
1299 +
1300 + // A refresh that does NOT clear the selection (the bulk-op path) must
1301 + // leave the last sample selected, not clamp it off by one. Before the
1302 + // fix this clamped to contents.len() and shifted the cursor to the
1303 + // second-to-last sample.
1304 + state.refresh_contents();
1305 + assert_eq!(state.selection.focus, last, "cursor must survive on the last row");
1306 + assert_eq!(
1307 + state.selected_node().map(|n| n.node.name),
1308 + before,
1309 + "detail panel must still resolve to the last sample, not the one before it"
1310 + );
1311 + }
1312 +
1313 + #[test]
1270 1314 fn selected_node_returns_none_for_parent_entry() {
1271 1315 let (mut state, _dir) = make_state();
1272 1316 let _dir_id = add_directory(&state, "Drums");
@@ -89,8 +89,11 @@ pub fn encode_aiff(audio: &ConvertedAudio, bit_depth: u16, dest: &Path) -> Resul
89 89 // Sample data (big-endian)
90 90 match bit_depth {
91 91 16 => {
92 - let seed = audio.samples.as_ptr() as u64 ^ audio.samples.len() as u64;
93 - let mut rng = SimpleRng::new(seed);
92 + // Fixed seed, same as the WAV encoder, so AIFF dithered exports are
93 + // reproducible too. A pointer-derived seed varied run-to-run with the
94 + // allocator address, making AIFF output nondeterministic and any
95 + // byte-parity test flaky by construction.
96 + let mut rng = SimpleRng::new(super::encode::DITHER_SEED);
94 97 let scale = i16::MAX as f32;
95 98 for &sample in &audio.samples {
96 99 let dither = (rng.next_f32() + rng.next_f32() - 1.0) / scale;
@@ -323,6 +326,24 @@ mod tests {
323 326 }
324 327
325 328 #[test]
329 + fn aiff_16bit_dither_is_reproducible() {
330 + // Regression (NF-1): 16-bit AIFF dither must use the fixed DITHER_SEED so
331 + // encoding the same input twice yields byte-identical files. A
332 + // pointer-derived seed varied with the allocator address.
333 + let dir = tempfile::tempdir().unwrap();
334 + let a = dir.path().join("a.aiff");
335 + let b = dir.path().join("b.aiff");
336 + let audio = make_audio(vec![0.1, -0.2, 0.3, -0.4, 0.05, -0.15, 0.25], 1, 44100);
337 + encode_aiff(&audio, 16, &a).unwrap();
338 + encode_aiff(&audio, 16, &b).unwrap();
339 + assert_eq!(
340 + std::fs::read(&a).unwrap(),
341 + std::fs::read(&b).unwrap(),
342 + "two 16-bit AIFF encodes of the same input must be byte-identical"
343 + );
344 + }
345 +
346 + #[test]
326 347 fn aiff_unsupported_bit_depth() {
327 348 let dir = tempfile::tempdir().unwrap();
328 349 let path = dir.path().join("test_32.aiff");