max / audiofiles
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"); |