1mod builder;
18mod layout;
19mod storage;
20
21use std::path::Path;
22use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
23
24use dspk_core::{DspError, DspResult, RecordingSource};
25
26use crate::envelope::fold::{finish, fold_block, merge, Block, Columns, EMPTY};
27use crate::read::read_pipelined;
28use layout::Layout;
29use storage::{Storage, HEADER_BYTES};
30
31pub use builder::{OnProgress, Progress, PyramidBuilder};
32pub use layout::PAGE_SEC;
33pub use storage::{pyramid_path, PyramidIdentity};
34
35pub const MEMORY_BASE: u64 = 256;
38pub const FILE_BASE: u64 = 64;
41
42const MERGING: u8 = 0;
45const DONE: u8 = u8::MAX;
46
47pub struct Pyramid {
49 storage: Storage,
50 layout: Layout,
51 claimed: Vec<AtomicU64>,
53 ready: Vec<AtomicU64>,
55 upper: Vec<Vec<AtomicU8>>,
57 marked_complete: AtomicBool,
58}
59
60unsafe impl Send for Pyramid {}
64unsafe impl Sync for Pyramid {}
65
66fn bits(n: u64) -> Vec<AtomicU64> {
67 (0..n.div_ceil(64)).map(|_| AtomicU64::new(0)).collect()
68}
69
70impl Pyramid {
71 fn with_storage(storage: Storage, layout: Layout, complete: bool) -> Self {
72 let full = |n: u64| -> Vec<AtomicU64> {
74 if !complete {
75 return bits(n);
76 }
77 (0..n.div_ceil(64))
78 .map(|w| {
79 let valid = (n - 64 * w).min(64);
80 AtomicU64::new(if valid == 64 { u64::MAX } else { (1 << valid) - 1 })
81 })
82 .collect()
83 };
84 let upper = (layout.page_level + 1..layout.levels.len())
85 .map(|k| {
86 let below = layout.levels[k - 1].buckets;
87 (0..layout.levels[k].buckets).map(|j| AtomicU8::new(if complete { DONE } else if 2 * j + 1 < below { 2 } else { 1 })).collect()
88 })
89 .collect();
90 Self { claimed: full(layout.pages), ready: full(layout.pages), upper, marked_complete: AtomicBool::new(complete), storage, layout }
91 }
92
93 pub fn in_memory(source: &dyn RecordingSource, base: u64) -> DspResult<Self> {
96 check_base(base)?;
97 let info = source.info();
98 let layout = Layout::new(info.channel_count(), info.samples, info.sample_rate_hz(), base, 0);
99 Ok(Self::with_storage(Storage::memory(layout.bytes)?, layout, false))
100 }
101
102 pub fn open_file(path: &Path, identity: &PyramidIdentity, base: u64) -> DspResult<Option<Self>> {
105 check_base(base)?;
106 let layout = Layout::new(identity.channels, identity.samples, identity.sample_rate_hz, base, HEADER_BYTES);
107 Ok(Storage::open_file(path, identity, base, layout.bytes)?.map(|s| Self::with_storage(s, layout, true)))
108 }
109
110 pub fn create_file(path: &Path, identity: &PyramidIdentity, base: u64) -> DspResult<Self> {
112 check_base(base)?;
113 let layout = Layout::new(identity.channels, identity.samples, identity.sample_rate_hz, base, HEADER_BYTES);
114 Ok(Self::with_storage(Storage::create_file(path, identity, base, layout.bytes)?, layout, false))
115 }
116
117 pub fn open_or_create(source: &dyn RecordingSource, recording: Option<(&Path, &str)>) -> DspResult<(Self, bool)> {
121 let Some((path, id)) = recording else { return Ok((Self::in_memory(source, MEMORY_BASE)?, false)) };
122 let identity = PyramidIdentity::of(path, id, source)?;
123 let file = pyramid_path(path, id);
124 if let Some(pyramid) = Self::open_file(&file, &identity, FILE_BASE)? {
125 return Ok((pyramid, true));
126 }
127 match Self::create_file(&file, &identity, FILE_BASE) {
128 Ok(pyramid) => Ok((pyramid, false)),
129 Err(e) => {
130 tracing::warn!("cannot write {}: {e}; keeping the min/max pyramid in memory", file.display());
131 Ok((Self::in_memory(source, MEMORY_BASE)?, false))
132 }
133 }
134 }
135
136 pub fn path(&self) -> Option<&Path> {
138 self.storage.path()
139 }
140
141 pub fn base(&self) -> u64 {
142 self.layout.base
143 }
144
145 pub fn samples(&self) -> u64 {
146 self.layout.samples
147 }
148
149 pub fn page_samples(&self) -> u64 {
151 self.layout.page_samples
152 }
153
154 fn page_ready(&self, page: u64) -> bool {
155 self.ready[(page / 64) as usize].load(Ordering::Acquire) & (1 << (page % 64)) != 0
156 }
157
158 fn bucket_ready(&self, level: usize, bucket: u64) -> bool {
159 if level <= self.layout.page_level {
160 self.page_ready(bucket * self.layout.levels[level].bucket / self.layout.page_samples)
161 } else {
162 self.upper[level - self.layout.page_level - 1][bucket as usize].load(Ordering::Acquire) == DONE
163 }
164 }
165
166 fn pages_of(&self, start: u64, end: u64) -> std::ops::Range<u64> {
167 let end = end.min(self.layout.samples);
168 if start >= end { 0..0 } else { start / self.layout.page_samples..end.div_ceil(self.layout.page_samples) }
169 }
170
171 pub fn covers(&self, start: u64, end: u64) -> bool {
173 self.pages_of(start, end).all(|p| self.page_ready(p))
174 }
175
176 pub fn is_complete(&self) -> bool {
178 let top = self.layout.levels.len() - 1;
179 if top > self.layout.page_level { self.bucket_ready(top, 0) } else { self.covers(0, self.layout.samples) }
180 }
181
182 fn slot(&self, level: usize, channel: usize, bucket: u64) -> *mut [f32; 2] {
183 unsafe { self.storage.ptr().add(self.layout.slot(level, channel, bucket)).cast() }
185 }
186
187 fn get(&self, level: usize, channel: usize, bucket: u64) -> [f32; 2] {
188 unsafe { self.slot(level, channel, bucket).read_unaligned() }
190 }
191
192 fn set(&self, level: usize, channel: usize, bucket: u64, value: [f32; 2]) {
193 unsafe { self.slot(level, channel, bucket).write_unaligned(value) }
195 }
196
197 fn merge_children(&self, level: usize, bucket: u64, children_end: u64) {
199 for ch in 0..self.layout.channels {
200 let a = self.get(level - 1, ch, 2 * bucket);
201 let v = if 2 * bucket + 1 < children_end { merge(a, self.get(level - 1, ch, 2 * bucket + 1)) } else { a };
202 self.set(level, ch, bucket, v);
203 }
204 }
205
206 fn build_page(&self, block: &Block, page: u64, scratch: &mut Vec<[f32; 2]>) {
208 let (word, bit) = ((page / 64) as usize, 1u64 << (page % 64));
209 if self.claimed[word].fetch_or(bit, Ordering::AcqRel) & bit != 0 {
210 return;
211 }
212 let l = &self.layout;
213 let range = l.page_range(page);
214 let first = l.buckets_of(0, &range);
215 let count = (first.end - first.start) as usize;
216 scratch.clear();
217 scratch.resize(l.channels * count, EMPTY);
218 let offset = (range.start - block.first) as usize;
219 let columns = Columns::Buckets { origin: range.start, size: l.base, count };
220 fold_block(block, offset..offset + (range.end - range.start) as usize, columns, scratch);
221 for (ch, row) in scratch.chunks_exact(count).enumerate() {
222 unsafe { std::ptr::copy_nonoverlapping(row.as_ptr(), self.slot(0, ch, first.start), count) };
224 }
225 for k in 1..=l.page_level {
226 let children_end = l.buckets_of(k - 1, &range).end;
227 for j in l.buckets_of(k, &range) {
228 self.merge_children(k, j, children_end);
229 }
230 }
231 self.ready[word].fetch_or(bit, Ordering::Release);
232
233 let mut child = page;
235 for k in l.page_level + 1..l.levels.len() {
236 let j = child / 2;
237 let state = &self.upper[k - l.page_level - 1][j as usize];
238 if state.fetch_sub(1, Ordering::AcqRel) != MERGING + 1 {
239 return;
240 }
241 self.merge_children(k, j, l.levels[k - 1].buckets);
242 state.store(DONE, Ordering::Release);
243 child = j;
244 }
245 }
246
247 pub fn fill(&self, source: &dyn RecordingSource, start: u64, end: u64, block_values: usize, stop: impl Fn() -> bool + Sync) -> DspResult<bool> {
253 let info = source.info();
254 if info.channel_count() != self.layout.channels || info.samples != self.layout.samples {
255 return Err(DspError::InvalidConfig("source does not match the min/max pyramid shape".into()));
256 }
257 let pages = self.pages_of(start, end);
258 if self.layout.channels == 0 || pages.is_empty() {
259 return Ok(true);
260 }
261 let page_values = self.layout.channels * self.layout.page_samples as usize;
262 let per_read = (block_values / page_values).max(1) as u64;
263 let taken = |p: u64| self.claimed[(p / 64) as usize].load(Ordering::Acquire) & (1 << (p % 64)) != 0;
264 let mut ranges = Vec::new();
265 let mut p = pages.start;
266 while p < pages.end {
267 if taken(p) {
268 p += 1;
269 continue;
270 }
271 let mut q = p + 1;
272 while q < pages.end && q - p < per_read && !taken(q) {
273 q += 1;
274 }
275 ranges.push(self.layout.page_range(p).start..self.layout.page_range(q - 1).end);
276 p = q;
277 }
278 let mut scratch = Vec::new();
279 let done = read_pipelined(source, ranges, stop, |block| {
280 let end = block.first + block.samples as u64;
281 for page in self.pages_of(block.first, end) {
282 self.build_page(block, page, &mut scratch);
283 }
284 })?;
285 if self.is_complete() && !self.marked_complete.swap(true, Ordering::AcqRel) {
286 self.storage.mark_complete()?;
287 }
288 Ok(done)
289 }
290
291 pub fn envelope(&self, channels: &[usize], start: u64, end: u64, width: usize, out: &mut [[f32; 2]]) -> DspResult<bool> {
296 let l = &self.layout;
297 if let Some(&channel) = channels.iter().find(|&&c| c >= l.channels) {
298 return Err(DspError::InvalidChannel { channel, total: l.channels });
299 }
300 if start >= end || end > l.samples || width == 0 {
301 return Err(DspError::SampleRange { start, end, total: l.samples });
302 }
303 if out.len() != channels.len() * width {
304 return Err(DspError::ShapeMismatch { expected: vec![channels.len(), width], actual: vec![out.len()] });
305 }
306 let n = end - start;
307 let Some(k) = l.view_level(n, width) else { return Ok(false) };
308 let bucket = l.levels[k].bucket;
309 let edge = |x: usize| (start + x as u64 * n / width as u64) / bucket;
310 for x in 0..width {
311 let j0 = edge(x);
312 let j1 = if x + 1 == width { end.div_ceil(bucket) } else { edge(x + 1) };
313 let built = (j0..j1).all(|j| self.bucket_ready(k, j));
314 for (r, &ch) in channels.iter().enumerate() {
315 out[r * width + x] = if built { (j0..j1).fold(EMPTY, |acc, j| merge(acc, self.get(k, ch, j))) } else { EMPTY };
316 }
317 }
318 finish(out);
319 Ok(true)
320 }
321
322 pub fn column_samples(&self, start: u64, end: u64, width: usize, x: usize) -> Option<(u64, u64)> {
325 let n = end.checked_sub(start)?;
326 let bucket = self.layout.levels[self.layout.view_level(n, width)?].bucket;
327 let edge = |x: usize| (start + x as u64 * n / width as u64) / bucket;
328 let j1 = if x + 1 == width { end.div_ceil(bucket) } else { edge(x + 1) };
329 Some((edge(x) * bucket, (j1 * bucket).min(self.layout.samples)))
330 }
331}
332
333fn check_base(base: u64) -> DspResult<()> {
334 if base == 0 {
335 return Err(DspError::InvalidConfig("min/max pyramid base must be at least 1 sample".into()));
336 }
337 Ok(())
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use dspk_core::MemoryRecording;
344 use std::path::PathBuf;
345 use std::sync::Arc;
346
347 fn recording(channels: usize, samples: usize) -> MemoryRecording {
348 let data: Vec<f32> = (0..channels * samples)
349 .map(|i| {
350 let (c, s) = (i / samples, i % samples);
351 ((s * 7919 + c * 104_729) % 1001) as f32 - 500.0 + if (s + 37 * c) % 4099 == 0 { -9_000.0 } else { 0.0 }
352 })
353 .collect();
354 MemoryRecording::new("rec", data, channels, 1000.0).unwrap()
355 }
356
357 fn expected(rec: &MemoryRecording, p: &Pyramid, channels: &[usize], start: u64, end: u64, width: usize) -> Vec<[f32; 2]> {
359 let mut out = vec![[0.0; 2]; channels.len() * width];
360 for x in 0..width {
361 let (c0, c1) = p.column_samples(start, end, width, x).unwrap();
362 let mut buf = vec![0.0f32; channels.len() * (c1 - c0) as usize];
363 rec.read(channels, c0..c1, &mut buf).unwrap();
364 for (r, row) in buf.chunks_exact((c1 - c0) as usize).enumerate() {
365 out[r * width + x] = row.iter().fold([f32::INFINITY, f32::NEG_INFINITY], |[a, b], &v| [a.min(v), b.max(v)]);
366 }
367 }
368 out
369 }
370
371 fn temp_dir(name: &str) -> PathBuf {
372 std::env::temp_dir().join(format!("dk_pyramid_{name}_{}", std::process::id()))
373 }
374
375 fn identity(rec: &MemoryRecording) -> PyramidIdentity {
376 let info = rec.info();
377 PyramidIdentity { source_id: "main".into(), len: 0, mtime_ns: 0, channels: info.channel_count(), samples: info.samples, sample_rate_hz: info.sample_rate_hz() }
378 }
379
380 fn check_matches_brute_force(rec: &MemoryRecording, p: &Pyramid) {
381 let channels = [4, 0, 2];
382 for (start, end, width) in [(0u64, 100_003u64, 97usize), (123, 99_000, 640), (5_000, 7_000, 100), (0, 100_003, 1)] {
383 let mut out = vec![[0.0; 2]; channels.len() * width];
384 assert!(p.envelope(&channels, start, end, width, &mut out).unwrap());
385 assert_eq!(out, expected(rec, p, &channels, start, end, width), "{start}..{end} @ {width}");
386 let spikes = (start..end).filter(|s| (*s as usize + 37 * 4).is_multiple_of(4099)).count();
387 if spikes > 0 {
388 assert!(out[..width].iter().any(|v| v[0] < -8_000.0), "every spike shows");
389 }
390 }
391 let mut out = vec![[0.0; 2]; 3 * 100];
392 assert!(!p.envelope(&channels, 0, 1_000, 100, &mut out).unwrap(), "finer than base: raw");
393 }
394
395 #[test]
396 fn memory_and_file_pyramids_match_brute_force() {
397 let rec = recording(5, 100_003);
398 let mem = Pyramid::in_memory(&rec, 16).unwrap();
399 assert!(mem.fill(&rec, 0, 100_003, 1 << 20, || false).unwrap());
400 assert!(mem.is_complete());
401 check_matches_brute_force(&rec, &mem);
402
403 let dir = temp_dir("match");
404 let file = Pyramid::create_file(&pyramid_path(&dir.join("rec.bin"), "main"), &identity(&rec), 16).unwrap();
405 assert!(file.fill(&rec, 0, 100_003, 1 << 20, || false).unwrap());
406 check_matches_brute_force(&rec, &file);
407 drop(file);
408 std::fs::remove_dir_all(&dir).unwrap();
409 }
410
411 #[test]
412 fn filling_in_any_order_gives_the_same_pyramid() {
413 let rec = recording(5, 100_003);
414 let p = Pyramid::in_memory(&rec, 16).unwrap();
415 for (s, e) in [(60_000, 70_000), (0, 3_000), (99_000, 100_003), (2_000, 100_003)] {
416 assert!(p.fill(&rec, s, e, 4_000, || false).unwrap());
417 }
418 assert!(p.is_complete());
419 check_matches_brute_force(&rec, &p);
420 }
421
422 #[test]
423 fn concurrent_fills_build_each_page_once_and_agree() {
424 let rec = Arc::new(recording(5, 100_003));
425 let p = Arc::new(Pyramid::in_memory(rec.as_ref(), 16).unwrap());
426 let threads: Vec<_> = (0..4)
427 .map(|t| {
428 let (rec, p) = (rec.clone(), p.clone());
429 std::thread::spawn(move || {
430 let start = t * 20_000;
431 p.fill(rec.as_ref(), start, 100_003, 3_000, || false).unwrap();
432 p.fill(rec.as_ref(), 0, start, 3_000, || false).unwrap();
433 })
434 })
435 .collect();
436 for t in threads {
437 t.join().unwrap();
438 }
439 assert!(p.is_complete());
440 check_matches_brute_force(&rec, &p);
441 }
442
443 #[test]
444 fn unbuilt_columns_are_nan_and_stopping_keeps_built_pages() {
445 let rec = recording(2, 50_000);
446 let p = Pyramid::in_memory(&rec, 8).unwrap();
447 assert!(!p.fill(&rec, 0, 50_000, 1 << 20, || true).unwrap(), "stopped before the first read");
448 assert!(!p.covers(0, 1));
449 assert!(p.fill(&rec, 0, 20_000, 1 << 20, || false).unwrap());
450 assert!(p.covers(0, 20_000) && !p.covers(0, 50_000));
451 let mut out = vec![[0.0; 2]; 2 * 50];
452 assert!(p.envelope(&[0, 1], 0, 50_000, 50, &mut out).unwrap());
453 let built = out[..50].iter().take_while(|v| v[0].is_finite()).count();
454 assert!(built > 0 && built < 50 && out[built..50].iter().all(|v| v[0].is_nan()));
455 }
456
457 #[test]
458 fn files_reopen_only_when_complete_and_unchanged() {
459 let rec = recording(2, 50_000);
460 let dir = temp_dir("reopen");
461 let path = pyramid_path(&dir.join("rec.bin"), "main");
462 let id = identity(&rec);
463
464 let p = Pyramid::create_file(&path, &id, 8).unwrap();
465 assert!(p.fill(&rec, 0, 20_000, 1 << 20, || false).unwrap());
466 drop(p);
467 assert!(Pyramid::open_file(&path, &id, 8).unwrap().is_none(), "incomplete files are rebuilt");
468
469 let p = Pyramid::create_file(&path, &id, 8).unwrap();
470 assert!(p.fill(&rec, 0, 50_000, 1 << 20, || false).unwrap());
471 drop(p);
472 let reopened = Pyramid::open_file(&path, &id, 8).unwrap().expect("complete files reopen");
473 assert!(reopened.is_complete() && reopened.covers(0, 50_000));
474 let mut out = vec![[0.0; 2]; 50];
475 assert!(reopened.envelope(&[1], 0, 50_000, 50, &mut out).unwrap());
476 assert!(out.iter().all(|v| v[0].is_finite()));
477 drop(reopened);
478 assert!(Pyramid::open_file(&path, &PyramidIdentity { mtime_ns: 1, ..id.clone() }, 8).unwrap().is_none());
479 assert!(Pyramid::open_file(&path, &id, 16).unwrap().is_none());
480 std::fs::remove_dir_all(&dir).unwrap();
481 }
482}