Skip to main content

dspk_view/pyramid/
mod.rs

1//! Min/max pyramids of a whole recording: exact envelopes at any zoom without reading every
2//! sample.
3//!
4//! One [`Pyramid`] serves every case. It is kept either in memory, for one session (base
5//! [`MEMORY_BASE`]), or in a file next to the recording, built once and reused (base
6//! [`FILE_BASE`]). It is filled page by page (about a second each) in any order: wherever a view
7//! looks ([`Pyramid::fill`]), or in the background nearest the view first ([`PyramidBuilder`]).
8//! Several threads may fill and read one pyramid at once; each page is built once.
9//!
10//! An envelope uses the coarsest level with at most one bucket per pixel column, with column
11//! edges aligned to its buckets: every sample falls into exactly one column, so no peak is lost
12//! (column edges move by less than one column). Columns finer than `base` samples are read raw.
13//!
14//! Files: `layout.rs` (bucket and page geometry), `storage.rs` (memory or file bytes, the file's
15//! identity and path), `builder.rs` (the background filler).
16
17mod 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
35/// Level-0 bucket (samples) of an in-memory pyramid. The pyramid holds about `4 / base` floats
36/// per sample, so 256 costs 1/64 of the recording as f32 (filled regions only).
37pub const MEMORY_BASE: u64 = 256;
38/// Level-0 bucket (samples) of a file pyramid: finer than [`MEMORY_BASE`] (zooms closer before
39/// raw reads take over) at 1/16 of the recording as f32, on disk.
40pub const FILE_BASE: u64 = 64;
41
42/// State of a bucket coarser than a page: children still to come (1 or 2), then `MERGING`
43/// while the last child's thread merges them, then `DONE`.
44const MERGING: u8 = 0;
45const DONE: u8 = u8::MAX;
46
47/// A min/max pyramid of one recording, in memory or in a file.
48pub struct Pyramid {
49    storage: Storage,
50    layout: Layout,
51    /// One bit per page: taken by the thread building it.
52    claimed: Vec<AtomicU64>,
53    /// One bit per page: built (published with `Release`).
54    ready: Vec<AtomicU64>,
55    /// Per level above `layout.page_level`, the state of each bucket.
56    upper: Vec<Vec<AtomicU8>>,
57    marked_complete: AtomicBool,
58}
59
60// SAFETY: page buckets are written only by the thread that claimed the page, before its ready
61// bit is set (`Release`); a coarser bucket only by the thread that took it to `MERGING`, before
62// `DONE` (`Release`). Readers only read buckets whose bit or state they saw (`Acquire`).
63unsafe 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        // One bit per page, all set for a complete pyramid
73        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    /// An empty pyramid of `source` in memory, with level-0 buckets of `base` samples
94    /// ([`MEMORY_BASE`] unless there is a reason otherwise).
95    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    /// The complete pyramid file at `path` built from `identity` with level-0 bucket `base`;
103    /// `None` when it is missing, incomplete or built from something else.
104    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    /// A new, empty pyramid file at `path` (replacing any other) to fill.
111    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    /// The pyramid of source `source_id` of the recording at `path`, base [`FILE_BASE`]: the
118    /// complete file next to it, else a new file there, else (no path, or a folder that cannot
119    /// be written) one in memory at [`MEMORY_BASE`]. Returns it and whether it is complete.
120    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    /// The file behind this pyramid, if any.
137    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    /// Samples per page (the unit of filling).
150    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    /// Whether every page holding samples `start..end` is built.
172    pub fn covers(&self, start: u64, end: u64) -> bool {
173        self.pages_of(start, end).all(|p| self.page_ready(p))
174    }
175
176    /// Whether every bucket of every level is built.
177    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        // SAFETY: `Layout::slot` stays inside the mapping of `layout.bytes`.
184        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        // SAFETY: callers read only built buckets (see the `Sync` note).
189        unsafe { self.slot(level, channel, bucket).read_unaligned() }
190    }
191
192    fn set(&self, level: usize, channel: usize, bucket: u64, value: [f32; 2]) {
193        // SAFETY: callers write only buckets they own (see the `Sync` note).
194        unsafe { self.slot(level, channel, bucket).write_unaligned(value) }
195    }
196
197    /// Merges bucket `bucket` of `level` from its children one level finer.
198    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    /// Builds page `page` from `block` (which holds its samples), unless another thread took it.
207    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            // SAFETY: level-0 buckets of this page are contiguous per channel and owned by us.
223            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        // Coarser levels: the last child of a bucket merges it, then reports to its parent
234        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    /// Builds the pages holding samples `start..end` that are not built yet, reading every
248    /// channel in the source's native order, consecutive pages together (up to `block_values`
249    /// values per read, at least one page), reading ahead while pages are built. Returns
250    /// `Ok(false)` when `stop` returned true before a read; pages built until then are kept. A
251    /// file is marked complete once every page is built.
252    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    /// `[min, max]` per pixel column of samples `start..end` for each of `channels`, written
292    /// row by row into `out` (`channels.len() × width`). Columns not built yet, or holding only
293    /// NaN samples, are NaN. Returns `false` (leaving `out` untouched) when a column spans fewer
294    /// than `base` samples: read those windows from the recording.
295    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    /// Sample range column `x` of a `width`-column envelope of `start..end` covers (the
323    /// bucket-aligned edges [`envelope`](Self::envelope) uses), or `None` for raw windows.
324    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    /// Brute-force min/max over each column's bucket-aligned sample range.
358    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}