From 5c517f4a7a8d4f6a08e515460bc6d106e48f0f3d Mon Sep 17 00:00:00 2001
From: botandbang <337765928+botandbang@users.noreply.github.com>
Date: Tue, 20 Oct 2026 22:04:43 -0400
Subject: [PATCH 1/5] anlz: beat grids, phrases and waveforms are written into
analysis files, new files made for a track without any, and 3-band waveforms
read for display
---
apps/locallibrary/docs/rpc/locallibrary.d.ts | 7 +-
apps/locallibrary/docs/rpc/schema.json | 5 +
crates/locallibrary/src/anlz.rs | 47 +-
crates/locallibrary/src/anlz/grid.rs | 75 ++++
crates/locallibrary/src/anlz/structure.rs | 147 ++++++
crates/locallibrary/src/anlz/tags.rs | 123 +++++
crates/locallibrary/src/anlz/waves.rs | 140 ++++++
crates/locallibrary/src/anlz/write.rs | 4 +-
crates/locallibrary/tests/it/analysis.rs | 6 +-
crates/locallibrary/tests/it/main.rs | 1 +
.../locallibrary/tests/it/props_anlz_write.rs | 422 ++++++++++++++++++
crates/loclib-model/src/analysis.rs | 56 +++
crates/loclib-model/src/analysis/tests.rs | 45 ++
13 files changed, 1067 insertions(+), 11 deletions(-)
create mode 100644 crates/locallibrary/src/anlz/grid.rs
create mode 100644 crates/locallibrary/src/anlz/structure.rs
create mode 100644 crates/locallibrary/src/anlz/tags.rs
create mode 100644 crates/locallibrary/src/anlz/waves.rs
create mode 100644 crates/locallibrary/tests/it/props_anlz_write.rs
diff --git a/apps/locallibrary/docs/rpc/locallibrary.d.ts b/apps/locallibrary/docs/rpc/locallibrary.d.ts
index fa94a947..c91dc392 100644
--- a/apps/locallibrary/docs/rpc/locallibrary.d.ts
+++ b/apps/locallibrary/docs/rpc/locallibrary.d.ts
@@ -2910,7 +2910,12 @@ export type WaveformStyle =
/** Monochrome blue, older players (`PWAV`, `PWV3`). */
| "blue"
/** Colour by frequency content, nexus 2 and later (`PWV4`, `PWV5`). */
- | "rgb";
+ | "rgb"
+ /**
+ * Lows, mids and highs drawn over one another, the CDJ-3000's (`PWV6`, `PWV7`), and what
+ * analyzer plugins give ([`Bands`]).
+ */
+ | "three_band";
/** One column of a waveform: a back layer and a (shorter, brighter) front layer drawn over it. */
export type WaveColumn = {
diff --git a/apps/locallibrary/docs/rpc/schema.json b/apps/locallibrary/docs/rpc/schema.json
index 6e10dd96..fa7b5078 100644
--- a/apps/locallibrary/docs/rpc/schema.json
+++ b/apps/locallibrary/docs/rpc/schema.json
@@ -13541,6 +13541,11 @@
"type": "string",
"const": "rgb",
"description": "Colour by frequency content, nexus 2 and later (`PWV4`, `PWV5`)."
+ },
+ {
+ "type": "string",
+ "const": "three_band",
+ "description": "Lows, mids and highs drawn over one another, the CDJ-3000's (`PWV6`, `PWV7`), and what\nanalyzer plugins give ([`Bands`])."
}
]
},
diff --git a/crates/locallibrary/src/anlz.rs b/crates/locallibrary/src/anlz.rs
index 81240e07..916b2c9d 100644
--- a/crates/locallibrary/src/anlz.rs
+++ b/crates/locallibrary/src/anlz.rs
@@ -3,9 +3,15 @@
//! Only what locallibrary needs: the audio path a file belongs to (`PPTH`, which must be
//! rewritten when a merge relocates a track), the beat grid (`PQTZ`) and cue points
//! (`PCOB`/`PCO2`) for XML export and editing (see [`write_cue_points`]), and the waveforms
-//! (`PWAV`/`PWV3`/`PWV4`/`PWV5`) and phrase analysis (`PSSI`) for display. Everything else is
-//! passed through byte for byte. What they decode to is [`loclib_model::analysis`]'s, re-exported
-//! here, and [`load`] reads it all for a track.
+//! (`PWAV`/`PWV3`/`PWV4`/`PWV5`, and `PWV6`/`PWV7` for a track with no others) and phrase analysis
+//! (`PSSI`) for display. Everything else is passed through byte for byte. What they decode to is
+//! [`loclib_model::analysis`]'s, re-exported here, and [`load`] reads it all for a track.
+//!
+//! The beat grid, phrases and waveforms can be written too ([`write_beat_grid`],
+//! [`write_song_structure`], [`write_waveform`]), and new files made for a track without any
+//! ([`new_analysis_files`]). Each writer reads what it wrote back with the reader here and fails
+//! when that isn't what it was given; these layouts are checked against rekordbox's own files, but
+//! only real players show what they make of them.
//!
//! All integers are big-endian. A file is a `PMAI` header followed by tagged sections, each
//! starting with `tag[4] header_len:u32 total_len:u32`. Layouts follow
@@ -16,9 +22,17 @@ use crate::model::Track;
use crate::paths;
use crate::{Error, Result};
+mod grid;
+mod structure;
+mod tags;
+mod waves;
mod write;
+pub use grid::{checked_grid, without_extended_grid, write_beat_grid};
pub use loclib_model::analysis::*;
+pub use structure::{kind_number, write_song_structure};
+pub use tags::new_analysis_files;
+pub use waves::{DETAIL_RATE, at_detail_rate, waveform_entries, waveform_from_bands, waveform_tags, write_waveform};
pub use write::{CueFile, CuePoint, RawCue, read_cue_points, shift_times, write_cue_points};
#[derive(Debug, Clone, Copy)]
@@ -261,7 +275,7 @@ fn detail_rate(bytes: &[u8], s: &SectionRef) -> f64 {
}
/// The whole-track overview waveform in an analysis file: colour (`PWV4`, in `.EXT`) if present,
-/// else blue (`PWAV`, in `.DAT`).
+/// else blue (`PWAV`, in `.DAT`), else 3-band (`PWV6`, in `.2EX`).
pub fn preview(bytes: &[u8]) -> Result> {
let secs = sections(bytes)?;
if let Some(s) = find(&secs, b"PWV4")
@@ -288,11 +302,16 @@ pub fn preview(bytes: &[u8]) -> Result > {
let columns = body.iter().copied().map(blue_column).collect();
return Ok(Some(Waveform { style: WaveformStyle::Blue, columns_per_sec: None, columns }));
}
+ if let Some(s) = find(&secs, b"PWV6")
+ && let Some(body) = entries(bytes, s, 3, Some(16))
+ {
+ return Ok(Some(waves::three_band(body, b"PWV6").waveform(None)));
+ }
Ok(None)
}
-/// The scrolling detail waveform in an analysis file (`.EXT`): colour (`PWV5`) if present, else
-/// blue (`PWV3`).
+/// The scrolling detail waveform in an analysis file: colour (`PWV5`, in `.EXT`) if present, else
+/// blue (`PWV3`, `.EXT`), else 3-band (`PWV7`, `.2EX`).
pub fn detail(bytes: &[u8]) -> Result > {
let secs = sections(bytes)?;
if let Some(s) = find(&secs, b"PWV5")
@@ -320,6 +339,11 @@ pub fn detail(bytes: &[u8]) -> Result > {
columns,
}));
}
+ if let Some(s) = find(&secs, b"PWV7")
+ && let Some(body) = entries(bytes, s, 3, Some(16))
+ {
+ return Ok(Some(waves::three_band(body, b"PWV7").waveform(Some(detail_rate(bytes, s)))));
+ }
Ok(None)
}
@@ -407,7 +431,8 @@ fn read_track(device: &Device, track: &Track, waveforms: bool) -> TrackAnalysis
}
let dat = paths::key(&track.analyze_path);
let read = |key: &str| device.checked_path(key).ok().and_then(|p| std::fs::read(p).ok());
- let ext = dat.strip_suffix(".DAT").map(|s| format!("{s}.EXT")).and_then(|k| read(&k));
+ let sibling = |ext: &str| dat.strip_suffix(".DAT").map(|s| format!("{s}.{ext}")).and_then(|k| read(&k));
+ let ext = sibling("EXT");
if let Some(bytes) = read(&dat) {
a.beats = beat_grid(&bytes).unwrap_or_default();
a.cues = cues(&bytes).unwrap_or_default();
@@ -431,6 +456,14 @@ fn read_track(device: &Device, track: &Track, waveforms: bool) -> TrackAnalysis
a.structure = song_structure(&bytes).ok().flatten();
}
}
+ // The 3-band waveforms (`.2EX`), only for a track with no others.
+ if waveforms
+ && (a.preview.is_none() || a.detail.is_none())
+ && let Some(bytes) = sibling("2EX")
+ {
+ a.preview = a.preview.or_else(|| preview(&bytes).ok().flatten());
+ a.detail = a.detail.or_else(|| detail(&bytes).ok().flatten());
+ }
a
}
diff --git a/crates/locallibrary/src/anlz/grid.rs b/crates/locallibrary/src/anlz/grid.rs
new file mode 100644
index 00000000..99b7fdf8
--- /dev/null
+++ b/crates/locallibrary/src/anlz/grid.rs
@@ -0,0 +1,75 @@
+//! Writing a beat grid (`PQTZ`, in `.DAT`).
+//!
+//! `PQTZ` ([DJL's "Beat Grid Tag"](https://djl-analysis.deepsymmetry.org/rekordbox-export-analysis/anlz.html#beat-grid-tag)):
+//! header 0x18, two u32 nobody has worked out (0 and 0x00080000 in rekordbox 6 exports, kept as a
+//! file has them), the beat count u32; then 8 bytes a beat: its number in its bar u16 (1-4), the
+//! tempo there u16 (BPM x 100) and its time u32 (ms).
+//!
+//! The `.EXT`'s extended grid (`PQT2`) repeats the first and last beat in its header, but its
+//! body (two bytes a beat: [pyrekordbox](https://github.com/dylanljones/pyrekordbox/blob/master/pyrekordbox/anlz/structs.py)
+//! calls the first `beat` and leaves the second unexplained, and in rekordbox 6 exports the first
+//! doesn't count beats in the bar) isn't understood, so it's never written: a new grid takes it out
+//! of the `.EXT`
+//! ([`without_extended_grid`]), leaving the file as rekordbox 5 exports were, without one. Moving
+//! a whole grid by some milliseconds keeps it ([`super::shift_times`]).
+
+use super::tags::{only, put, section};
+use super::*;
+
+/// The header fields `PQTZ` has before its beat count, as rekordbox 6 writes them.
+const HEADER: [u8; 8] = [0, 0, 0, 0, 0, 0x08, 0, 0];
+
+/// The beats a grid can hold: in order, each 1-4 in its bar, at a tempo of hundredths of a BPM
+/// up to 655.35. The tempos as `PQTZ` keeps them (to hundredths), or why not.
+pub fn checked_grid(beats: &[Beat]) -> Result> {
+ if beats.is_empty() {
+ return Err(Error::invalid("a beat grid needs a beat"));
+ }
+ let mut out = Vec::with_capacity(beats.len());
+ for (i, b) in beats.iter().enumerate() {
+ let n = i + 1;
+ if !(1..=4).contains(&b.beat_number) {
+ return Err(Error::invalid(format!("beat {n} is beat {} of its bar: 1-4", b.beat_number)));
+ }
+ let hundredths = (b.bpm * 100.0).round();
+ if !(1.0..=f64::from(u16::MAX)).contains(&hundredths) {
+ return Err(Error::invalid(format!("beat {n}'s tempo, {} BPM, isn't 0.01-655.35", b.bpm)));
+ }
+ if out.last().is_some_and(|p: &Beat| p.time_ms >= b.time_ms) {
+ return Err(Error::invalid(format!("beat {n} isn't after the one before it")));
+ }
+ out.push(Beat { bpm: hundredths / 100.0, ..*b });
+ }
+ u32::try_from(out.len()).map_err(|_| Error::invalid("too many beats"))?;
+ Ok(out)
+}
+
+/// A copy of an analysis file (a `.DAT`) holding `beats` as its beat grid ([`checked_grid`]),
+/// added when it has none. Unchanged beats keep the file as it was, byte for byte.
+pub fn write_beat_grid(bytes: &[u8], beats: &[Beat]) -> Result> {
+ let beats = checked_grid(beats)?;
+ let secs = sections(bytes)?;
+ let existing = only(&secs, b"PQTZ")?;
+ let mut header = existing
+ .filter(|s| s.header_len == 0x18)
+ .map_or(HEADER.to_vec(), |s| bytes[s.start + 12..s.start + 20].to_vec());
+ header.extend_from_slice(&(beats.len() as u32).to_be_bytes());
+ let mut body = Vec::with_capacity(beats.len() * 8);
+ for b in &beats {
+ body.extend_from_slice(&b.beat_number.to_be_bytes());
+ body.extend_from_slice(&((b.bpm * 100.0).round() as u16).to_be_bytes());
+ body.extend_from_slice(&b.time_ms.to_be_bytes());
+ }
+ let out = put(bytes, b"PQTZ", Some(§ion(b"PQTZ", &header, &body)?))?;
+ // Never a grid the reader doesn't read back as given.
+ if beat_grid(&out)? != beats {
+ return Err(malformed("the beat grid written doesn't read back"));
+ }
+ Ok(out)
+}
+
+/// A copy of an analysis file (an `.EXT`) without its extended beat grid (`PQT2`), which a new
+/// grid would contradict (see the module's docs). One without stays as it is.
+pub fn without_extended_grid(bytes: &[u8]) -> Result> {
+ put(bytes, b"PQT2", None)
+}
diff --git a/crates/locallibrary/src/anlz/structure.rs b/crates/locallibrary/src/anlz/structure.rs
new file mode 100644
index 00000000..bec3514c
--- /dev/null
+++ b/crates/locallibrary/src/anlz/structure.rs
@@ -0,0 +1,147 @@
+//! Writing phrases (`PSSI`, the song structure, in `.EXT`).
+//!
+//! `PSSI` ([DJL's "Song Structure Tag"](https://djl-analysis.deepsymmetry.org/rekordbox-export-analysis/anlz.html#song-structure-tag)):
+//! header 0x20: the entry length u32 (24), the phrase count u16, the mood u16 (1 high, 2 mid, 3
+//! low), 6 bytes, the beat the last phrase ends on u16, 2 bytes, the lighting bank u8 and a byte.
+//! Then 24 bytes a phrase: its number u16 (from 1), the beat it starts on u16, its kind u16 (named
+//! by the mood), 15 bytes of variation flags and beats, a fill-in flag u8 at 21 and the beat the
+//! fill-in starts on u16 at 22. rekordbox 6 and later mask everything after the count with an XOR
+//! mask ([`PSSI_MASK`] plus the count); the reader takes a mood past 20 for masked.
+//!
+//! Rewriting keeps a file's header bytes and every byte of a phrase entry whose start, kind and
+//! fill-in stay the same; a new entry has its flags at 0 (the kind's first variation) and new
+//! headers bank 0. It's masked as the file's was (new ones as rekordbox 6 writes them), unless
+//! masking would make it read as unmasked (a mask byte of 0 over a mood), when it isn't.
+
+use super::tags::{only, put, section};
+use super::write::{put_u16, put_u32};
+use super::*;
+
+const ENTRY: usize = 24;
+
+/// rekordbox's kind number for a phrase of `kind` in a track of `mood`: the first of those named
+/// so (low verses have three numbers each). `None` for one the mood has no name for.
+pub fn kind_number(mood: Mood, kind: PhraseKind) -> Option {
+ let n = match (mood, kind) {
+ (_, PhraseKind::Intro) => 1,
+ (Mood::High, PhraseKind::Up) => 2,
+ (Mood::High, PhraseKind::Down) => 3,
+ (Mood::High, PhraseKind::Chorus) => 5,
+ (Mood::High, PhraseKind::Outro) => 6,
+ (Mood::Mid, PhraseKind::Verse(n @ 1..=6)) => u16::from(n) + 1,
+ (Mood::Low, PhraseKind::Verse(1)) => 2,
+ (Mood::Low, PhraseKind::Verse(2)) => 5,
+ (Mood::Mid | Mood::Low, PhraseKind::Bridge) => 8,
+ (Mood::Mid | Mood::Low, PhraseKind::Chorus) => 9,
+ (Mood::Mid | Mood::Low, PhraseKind::Outro) => 10,
+ (_, PhraseKind::Unknown(k)) if phrase_kind(mood, k) == PhraseKind::Unknown(k) => k,
+ _ => return None,
+ };
+ Some(n)
+}
+
+fn mood_number(mood: Mood) -> u16 {
+ match mood {
+ Mood::High => 1,
+ Mood::Mid => 2,
+ Mood::Low => 3,
+ }
+}
+
+/// Phrases a `PSSI` can hold: at least one, each starting after the one before on beat 1 or
+/// later, ending where the next starts (the last after it starts), of a kind the mood names, with
+/// a fill-in (if any) on beat 1 or later.
+fn check(s: &SongStructure) -> Result<()> {
+ if s.phrases.is_empty() {
+ return Err(Error::invalid("phrases need a phrase"));
+ }
+ if s.phrases.len() > usize::from(u16::MAX) {
+ return Err(Error::invalid("too many phrases"));
+ }
+ for (i, p) in s.phrases.iter().enumerate() {
+ let n = i + 1;
+ let next = s.phrases.get(i + 1).map_or(p.end_beat, |q| q.start_beat);
+ if p.start_beat == 0 || p.end_beat <= p.start_beat || p.end_beat != next {
+ return Err(Error::invalid(format!(
+ "phrase {n} runs from beat {} to {}: phrases follow one another from beat 1",
+ p.start_beat, p.end_beat
+ )));
+ }
+ if kind_number(s.mood, p.kind).is_none() {
+ return Err(Error::invalid(format!("phrase {n}: a {:?} track has no {} phrases", s.mood, p.kind.name())));
+ }
+ if p.fill_beat == Some(0) {
+ return Err(Error::invalid(format!("phrase {n}'s fill-in starts on beat 0: beats count from 1")));
+ }
+ }
+ Ok(())
+}
+
+/// The bytes after the count XORed with the mask (it undoes itself).
+fn masked(body: &mut [u8], count: u16) {
+ for (i, b) in body.iter_mut().enumerate() {
+ *b ^= PSSI_MASK[i % PSSI_MASK.len()].wrapping_add(count as u8);
+ }
+}
+
+/// A copy of an analysis file (an `.EXT`) holding `phrases` as its song structure, added when it
+/// has none, or without one (`None`). Phrases as they are keep the file as it was, byte for byte.
+pub fn write_song_structure(bytes: &[u8], phrases: Option<&SongStructure>) -> Result> {
+ let Some(s) = phrases else { return put(bytes, b"PSSI", None) };
+ check(s)?;
+ let secs = sections(bytes)?;
+ let existing = only(&secs, b"PSSI")?.filter(|e| e.header_len == 0x20);
+ // The existing section unmasked, from the entry length on, with whether it was masked.
+ let old = existing.map(|e| {
+ let mut b = bytes[e.start + 12..e.start + e.total_len].to_vec();
+ let count = be_u16(&b, 4).unwrap_or(0);
+ let was = be_u16(&b, 6).is_some_and(|m| m > 20);
+ if was {
+ masked(&mut b[6..], count);
+ }
+ (b, was)
+ });
+ let old_mood = old.as_ref().and_then(|(b, _)| be_u16(b, 6));
+ let old_entries: Vec<&[u8]> = match &old {
+ Some((b, _)) if old_mood == Some(mood_number(s.mood)) => b[20..].chunks_exact(ENTRY).collect(),
+ _ => Vec::new(),
+ };
+ let count = s.phrases.len() as u16;
+ let mut head = old.as_ref().map_or_else(|| vec![0; 20], |(b, _)| b[..20].to_vec());
+ put_u32(&mut head, 0, ENTRY as u32);
+ put_u16(&mut head, 4, count);
+ put_u16(&mut head, 6, mood_number(s.mood));
+ put_u16(&mut head, 14, s.phrases.last().expect("checked").end_beat);
+ let mut plain = head;
+ for (i, p) in s.phrases.iter().enumerate() {
+ let kept = old_entries.get(i).filter(|e| {
+ let fill = (e[21] != 0).then(|| be_u16(e, 22).unwrap_or(0)).filter(|b| *b > 0);
+ be_u16(e, 2) == Some(p.start_beat)
+ && be_u16(e, 4).map(|k| phrase_kind(s.mood, k)) == Some(p.kind)
+ && fill == p.fill_beat
+ });
+ let mut e = kept.map_or_else(|| vec![0; ENTRY], |e| e.to_vec());
+ put_u16(&mut e, 0, i as u16 + 1);
+ if kept.is_none() {
+ put_u16(&mut e, 2, p.start_beat);
+ put_u16(&mut e, 4, kind_number(s.mood, p.kind).expect("checked"));
+ e[21] = u8::from(p.fill_beat.is_some());
+ put_u16(&mut e, 22, p.fill_beat.unwrap_or(0));
+ }
+ plain.extend_from_slice(&e);
+ }
+ let want = Some(SongStructure { mood: s.mood, phrases: s.phrases.clone() });
+ let mask = old.as_ref().is_none_or(|(_, was)| *was);
+ for mask in [mask, false] {
+ let mut body = plain.clone();
+ if mask {
+ masked(&mut body[6..], count);
+ }
+ let out = put(bytes, b"PSSI", Some(§ion(b"PSSI", &body[..20], &body[20..])?))?;
+ // Never phrases the reader doesn't read back as given.
+ if song_structure(&out)? == want {
+ return Ok(out);
+ }
+ }
+ Err(malformed("the phrases written don't read back"))
+}
diff --git a/crates/locallibrary/src/anlz/tags.rs b/crates/locallibrary/src/anlz/tags.rs
new file mode 100644
index 00000000..e3914f4a
--- /dev/null
+++ b/crates/locallibrary/src/anlz/tags.rs
@@ -0,0 +1,123 @@
+//! Replacing, adding and removing one tagged section of an analysis file, for the writers of beat
+//! grids, phrases and waveforms (the cue lists have their own, [`super::write_cue_points`]), and
+//! making new files.
+//!
+//! A section is `tag[4] header_len:u32 total_len:u32`, the rest of its header and its body. A file
+//! holding a tag twice (never seen, but for the cue lists) is refused rather than guessed at.
+
+use super::write::put_u32;
+use super::*;
+
+/// The tags of rekordbox's analysis files in the order it writes them, all three kinds together
+/// (`PPTH` and the cue lists are in each; every other tag in one kind): observed in rekordbox 6
+/// exports (`.DAT`: `PPTH PVBR PQTZ PWAV PWV2 PCOB PCOB`; `.EXT`: `PPTH PWV3 PCOB PCOB PCO2 PCO2
+/// PQT2 PWV5 PWV4 PSSI`; `.2EX`: `PPTH PWV7 PWV6 PWVC`). A section added goes right after the last
+/// one before it here.
+const ORDER: [&[u8; 4]; 15] = [
+ b"PPTH", b"PVBR", b"PQTZ", b"PWAV", b"PWV2", b"PWV3", b"PCOB", b"PCO2", b"PQT2", b"PWV5", b"PWV4", b"PSSI",
+ b"PWV7", b"PWV6", b"PWVC",
+];
+
+/// A section: `tag`, its lengths, `header` (the rest of its header) and `body`.
+pub(super) fn section(tag: &[u8; 4], header: &[u8], body: &[u8]) -> Result> {
+ let too_large = || malformed("section too large");
+ let header_len = u32::try_from(12 + header.len()).map_err(|_| too_large())?;
+ let total_len = u32::try_from(12 + header.len() + body.len()).map_err(|_| too_large())?;
+ let mut s = Vec::with_capacity(total_len as usize);
+ s.extend_from_slice(tag);
+ s.extend_from_slice(&header_len.to_be_bytes());
+ s.extend_from_slice(&total_len.to_be_bytes());
+ s.extend_from_slice(header);
+ s.extend_from_slice(body);
+ Ok(s)
+}
+
+/// The one section tagged `tag`, if the file has it: refused when it has two.
+pub(super) fn only<'a>(secs: &'a [SectionRef], tag: &[u8; 4]) -> Result> {
+ let mut found = secs.iter().filter(|s| &s.tag == tag);
+ let first = found.next();
+ if found.next().is_some() {
+ let name = String::from_utf8_lossy(tag);
+ return Err(Error::invalid(format!("the analysis file holds two {name} sections; left as it is")));
+ }
+ Ok(first)
+}
+
+/// A copy of an analysis file with its section `tag` replaced by `new` (a whole section, as
+/// [`section`] makes), added where rekordbox puts it when the file has none, or removed when
+/// `new` is `None`. Every other section comes through byte for byte, in its place (checked), and
+/// so does anything after them.
+pub(super) fn put(bytes: &[u8], tag: &[u8; 4], new: Option<&[u8]>) -> Result> {
+ let secs = sections(bytes)?;
+ let at = only(&secs, tag)?.map(|s| s.start);
+ let header_len = be_u32(bytes, 4).expect("checked by sections") as usize;
+ let file_len = be_u32(bytes, 8).expect("checked by sections") as usize;
+ let rank = |t: &[u8; 4]| ORDER.iter().position(|o| *o == t);
+ let mut out = bytes[..header_len].to_vec();
+ let mut placed = false;
+ let mut place = |out: &mut Vec| {
+ if let Some(new) = new.filter(|_| !placed) {
+ out.extend_from_slice(new);
+ }
+ placed = true;
+ };
+ for s in &secs {
+ // Missing, it goes before the first section that comes after it in rekordbox's order.
+ let after = rank(&s.tag).zip(rank(tag)).is_some_and(|(theirs, ours)| theirs > ours);
+ if at.is_none() && after {
+ place(&mut out);
+ }
+ if Some(s.start) == at {
+ place(&mut out);
+ continue;
+ }
+ out.extend_from_slice(&bytes[s.start..s.start + s.total_len]);
+ }
+ place(&mut out);
+ let last_end = secs.last().map_or(header_len, |s| s.start + s.total_len);
+ out.extend_from_slice(&bytes[last_end..file_len]);
+ let len = u32::try_from(out.len()).map_err(|_| malformed("file too large"))?;
+ put_u32(&mut out, 8, len);
+ // Anything after the declared file length (never seen in practice) is preserved.
+ out.extend_from_slice(&bytes[file_len..]);
+
+ let others = |b: &[u8], secs: &[SectionRef]| -> Vec> {
+ secs.iter().filter(|s| &s.tag != tag).map(|s| b[s.start..s.start + s.total_len].to_vec()).collect()
+ };
+ let written = sections(&out)?;
+ let mine: Vec<&[u8]> =
+ written.iter().filter(|s| &s.tag == tag).map(|s| &out[s.start..s.start + s.total_len]).collect();
+ if others(&out, &written) != others(bytes, &secs) || mine != new.into_iter().collect::>() {
+ return Err(malformed("rewriting a section disturbed the others"));
+ }
+ Ok(out)
+}
+
+/// A new analysis file for the audio file at `audio` (a device path, `/Contents/...`): the
+/// `PMAI` header and the `PPTH` path rekordbox 6 writes, and nothing else yet.
+fn new_file(audio: &str) -> Result> {
+ let mut path: Vec = audio.encode_utf16().flat_map(u16::to_be_bytes).collect();
+ path.extend_from_slice(&[0, 0]);
+ let len = u32::try_from(path.len()).map_err(|_| Error::invalid("the audio file's path is too long"))?;
+ let ppth = section(b"PPTH", &len.to_be_bytes(), &path)?;
+ // As every rekordbox 6 export's files have it: what the values after the lengths mean isn't
+ // known.
+ let mut f = b"PMAI".to_vec();
+ for v in [0x1c, 0, 1, 0x0001_0000, 0x0001_0000, 0] {
+ f.extend_from_slice(&u32::to_be_bytes(v));
+ }
+ f.extend_from_slice(&ppth);
+ let total = f.len() as u32;
+ put_u32(&mut f, 8, total);
+ Ok(f)
+}
+
+/// New `.DAT` and `.EXT` files for a track without any, its audio file at `audio` (a device
+/// path): each with its path (`PPTH`) and the empty cue lists rekordbox writes for a track
+/// without cues, for the writers of grids, phrases and waveforms to add to.
+pub fn new_analysis_files(audio: &str) -> Result<(Vec, Vec)> {
+ let base = new_file(audio)?;
+ let dat = write_cue_points(&base, CueFile::Dat, &[])?;
+ let ext = write_cue_points(&base, CueFile::Ext, &[])?;
+ Ok((dat, ext))
+}
diff --git a/crates/locallibrary/src/anlz/waves.rs b/crates/locallibrary/src/anlz/waves.rs
new file mode 100644
index 00000000..e4aa730c
--- /dev/null
+++ b/crates/locallibrary/src/anlz/waves.rs
@@ -0,0 +1,140 @@
+//! Writing waveforms: the blue preview and detail (`PWAV` and `PWV2` in `.DAT`, `PWV3` in
+//! `.EXT`), the colour ones (`PWV4`, `PWV5`, `.EXT`) and the 3-band ones (`PWV6`, `PWV7`, `.2EX`),
+//! as [DJL's waveform tags](https://djl-analysis.deepsymmetry.org/rekordbox-export-analysis/anlz.html#waveform-preview-tag)
+//! lay them out: after the lengths, previews (`PWAV`, `PWV2`) have their column count u32 and a
+//! u32 (0x00010000 in rekordbox 6 exports); the others the bytes a column u32 and the count u32,
+//! then (but `PWV6`) a u32 starting with the columns a second u16 (0x0096, 150). A file's header
+//! fields are kept as it has them; new ones are rekordbox 6's.
+//!
+//! [`write_waveform`] writes a tag's columns as given, and [`waveform_from_bands`] works them out
+//! from a three-band waveform ([`Bands`], 150 columns a second) for every tag whose bytes are
+//! all understood: a blue column is its height in the low 5 bits and its whiteness (here the
+//! highs' share) in the top 3; `PWV2` a height of 4 bits; `PWV5` red, green and blue (lows, mids
+//! and highs against the loudest) in 3 bits each, then the height in 5 and 2 unused; 3-band
+//! columns are the mids', highs' and lows' heights ([beat-link] draws `PWV6` from 0-63 and
+//! `PWV7` from 0-127, so they're a quarter and half of a band's 0-255 here). `PWV4`'s first two
+//! bytes a column aren't understood, so it's never worked out. These mappings are locallibrary's
+//! own: players draw what they're given, but only real players show whether it looks right.
+//!
+//! [beat-link]: https://github.com/Deep-Symmetry/beat-link/blob/main/src/main/java/org/deepsymmetry/beatlink/data/WaveformDetail.java
+
+use super::tags::{only, put, section};
+use super::write::put_u32;
+use super::*;
+
+/// A waveform tag's layout: the bytes a column, the header fields after the lengths as rekordbox
+/// 6 writes them (the count left 0), where in the section the count is, and a preview's columns.
+struct Spec {
+ tag: [u8; 4],
+ entry: usize,
+ header: &'static [u8],
+ count_at: usize,
+ preview: Option,
+}
+
+const SPECS: [Spec; 7] = [
+ Spec { tag: *b"PWAV", entry: 1, header: &[0, 0, 0, 0, 0, 1, 0, 0], count_at: 12, preview: Some(400) },
+ Spec { tag: *b"PWV2", entry: 1, header: &[0, 0, 0, 0, 0, 1, 0, 0], count_at: 12, preview: Some(100) },
+ Spec { tag: *b"PWV3", entry: 1, header: &[0, 0, 0, 1, 0, 0, 0, 0, 0, 0x96, 0, 0], count_at: 16, preview: None },
+ Spec { tag: *b"PWV4", entry: 6, header: &[0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0], count_at: 16, preview: Some(1200) },
+ Spec { tag: *b"PWV5", entry: 2, header: &[0, 0, 0, 2, 0, 0, 0, 0, 0, 0x96, 3, 5], count_at: 16, preview: None },
+ Spec { tag: *b"PWV6", entry: 3, header: &[0, 0, 0, 3, 0, 0, 0, 0], count_at: 16, preview: Some(1200) },
+ Spec { tag: *b"PWV7", entry: 3, header: &[0, 0, 0, 3, 0, 0, 0, 0, 0, 0x96, 0, 0], count_at: 16, preview: None },
+];
+
+/// The columns a second of the detail waveforms (`PWV3`, `PWV5`, `PWV7`) written here.
+pub const DETAIL_RATE: f64 = 150.0;
+
+/// A 3-band column's height in `PWV6` and `PWV7`, as a fraction of a band's (0-255).
+const PWV6_SHIFT: u32 = 2;
+const PWV7_SHIFT: u32 = 1;
+
+fn spec(tag: &[u8; 4]) -> Result<&'static Spec> {
+ SPECS
+ .iter()
+ .find(|s| &s.tag == tag)
+ .ok_or_else(|| Error::invalid(format!("{} isn't a waveform tag", String::from_utf8_lossy(tag))))
+}
+
+/// The waveform tags, in rekordbox's order within each file.
+pub fn waveform_tags() -> impl Iterator- {
+ SPECS.iter().map(|s| &s.tag)
+}
+
+/// The columns of an analysis file's waveform `tag` (a `PWV...` or `PWAV`), as stored: as many
+/// as its header says, no more than are there.
+pub fn waveform_entries(bytes: &[u8], tag: &[u8; 4]) -> Result
>> {
+ let spec = spec(tag)?;
+ let secs = sections(bytes)?;
+ Ok(only(&secs, tag)?.and_then(|s| entries(bytes, s, spec.entry, Some(spec.count_at))).map(<[u8]>::to_vec))
+}
+
+/// A copy of an analysis file whose waveform `tag` holds `columns` (as stored, whole columns),
+/// added where rekordbox puts it when it has none. The file's header fields are kept; the same
+/// columns keep the file as it was, byte for byte.
+pub fn write_waveform(bytes: &[u8], tag: &[u8; 4], columns: &[u8]) -> Result> {
+ let spec = spec(tag)?;
+ let name = String::from_utf8_lossy(tag);
+ if columns.is_empty() || !columns.len().is_multiple_of(spec.entry) {
+ return Err(Error::invalid(format!("{name} takes whole columns of {} bytes", spec.entry)));
+ }
+ let count = u32::try_from(columns.len() / spec.entry).map_err(|_| Error::invalid("too many columns"))?;
+ let secs = sections(bytes)?;
+ let header_len = 12 + spec.header.len();
+ let kept = only(&secs, tag)?.filter(|s| s.header_len == header_len);
+ let mut header = kept.map_or(spec.header.to_vec(), |s| bytes[s.start + 12..s.start + header_len].to_vec());
+ put_u32(&mut header, spec.count_at - 12, count);
+ let out = put(bytes, tag, Some(§ion(tag, &header, columns)?))?;
+ // Never a waveform the reader doesn't read back as given.
+ if waveform_entries(&out, tag)?.as_deref() != Some(columns) {
+ return Err(malformed(&format!("the {name} waveform written doesn't read back")));
+ }
+ Ok(out)
+}
+
+/// `bands` (at `rate` columns a second) at [`DETAIL_RATE`], as long.
+pub fn at_detail_rate(bands: &Bands, rate: f64) -> Bands {
+ let n = (bands.0.len() as f64 * DETAIL_RATE / rate).round() as usize;
+ bands.pooled(n.max(1))
+}
+
+/// The columns of waveform `tag` worked out from `bands` (the detail, at [`DETAIL_RATE`]; previews
+/// are pooled from it), as stored. `None` for `PWV4`, whose bytes aren't all understood.
+pub fn waveform_from_bands(tag: &[u8; 4], bands: &Bands) -> Result>> {
+ let spec = spec(tag)?;
+ let bands = match spec.preview {
+ Some(n) => bands.pooled(n),
+ None => bands.clone(),
+ };
+ let scaled = |v: u8, most: u32, of: u8| (u32::from(v) * most / u32::from(of.max(1))) as u8;
+ let loudest = |c: &[u8; 3]| c[0].max(c[1]).max(c[2]);
+ let blue = |c: &[u8; 3]| {
+ let top = loudest(c);
+ (scaled(c[2], 7, top) << 5) | scaled(top, 31, 255)
+ };
+ let columns: Vec = match tag {
+ b"PWAV" | b"PWV3" => bands.0.iter().map(blue).collect(),
+ b"PWV2" => bands.0.iter().map(|c| scaled(loudest(c), 15, 255)).collect(),
+ b"PWV5" => bands
+ .0
+ .iter()
+ .flat_map(|c| {
+ let top = loudest(c);
+ let [r, g, b] = c.map(|v| u16::from(scaled(v, 7, top)));
+ let v = (r << 13) | (g << 10) | (b << 7) | (u16::from(scaled(top, 31, 255)) << 2);
+ v.to_be_bytes()
+ })
+ .collect(),
+ b"PWV6" => bands.0.iter().flat_map(|c| [c[1], c[2], c[0]].map(|v| v >> PWV6_SHIFT)).collect(),
+ b"PWV7" => bands.0.iter().flat_map(|c| [c[1], c[2], c[0]].map(|v| v >> PWV7_SHIFT)).collect(),
+ _ => return Ok(None),
+ };
+ Ok(Some(columns))
+}
+
+/// A 3-band waveform's stored columns (mids, highs, lows) as [`Bands`], for drawing.
+pub(super) fn three_band(columns: &[u8], tag: &[u8; 4]) -> Bands {
+ let shift = if tag == b"PWV6" { PWV6_SHIFT } else { PWV7_SHIFT };
+ let up = |v: u8| (u32::from(v) << shift).min(255) as u8;
+ Bands(columns.chunks_exact(3).map(|c| [up(c[2]), up(c[0]), up(c[1])]).collect())
+}
diff --git a/crates/locallibrary/src/anlz/write.rs b/crates/locallibrary/src/anlz/write.rs
index 40d5ef22..a2ae354f 100644
--- a/crates/locallibrary/src/anlz/write.rs
+++ b/crates/locallibrary/src/anlz/write.rs
@@ -145,11 +145,11 @@ impl CueFile {
}
}
-fn put_u16(b: &mut [u8], o: usize, v: u16) {
+pub(super) fn put_u16(b: &mut [u8], o: usize, v: u16) {
b[o..o + 2].copy_from_slice(&v.to_be_bytes());
}
-fn put_u32(b: &mut [u8], o: usize, v: u32) {
+pub(super) fn put_u32(b: &mut [u8], o: usize, v: u32) {
b[o..o + 4].copy_from_slice(&v.to_be_bytes());
}
diff --git a/crates/locallibrary/tests/it/analysis.rs b/crates/locallibrary/tests/it/analysis.rs
index 0b00824b..9c538ba5 100644
--- a/crates/locallibrary/tests/it/analysis.rs
+++ b/crates/locallibrary/tests/it/analysis.rs
@@ -59,7 +59,11 @@ fn falls_back_to_blue_preview_without_ext() {
let preview = a.preview.expect("blue preview from .DAT");
assert_eq!(preview.style, WaveformStyle::Blue);
assert_eq!(preview.columns.len(), 400);
- assert!(a.detail.is_none());
+ // The scrolling one is the .2EX's 3-band one, then: there's no other.
+ let detail = a.detail.expect("3-band detail from .2EX");
+ assert_eq!((detail.style, detail.columns_per_sec), (WaveformStyle::ThreeBand, Some(150.0)));
+ std::fs::remove_file(dat.with_extension("2EX")).unwrap();
+ assert!(anlz::load(&d, t).detail.is_none());
}
#[test]
diff --git a/crates/locallibrary/tests/it/main.rs b/crates/locallibrary/tests/it/main.rs
index 04128ffb..29aa67f5 100644
--- a/crates/locallibrary/tests/it/main.rs
+++ b/crates/locallibrary/tests/it/main.rs
@@ -49,6 +49,7 @@ mod profile;
mod props_after_set;
mod props_anlz;
mod props_anlz_phrases;
+mod props_anlz_write;
mod props_api;
mod props_api_apply;
mod props_api_art;
diff --git a/crates/locallibrary/tests/it/props_anlz_write.rs b/crates/locallibrary/tests/it/props_anlz_write.rs
new file mode 100644
index 00000000..c179bcbc
--- /dev/null
+++ b/crates/locallibrary/tests/it/props_anlz_write.rs
@@ -0,0 +1,422 @@
+//! Beat grids, phrases and waveforms written into analysis files: rekordbox's own files rewrite
+//! byte for byte with what they hold, and anything written reads back as given (with the reader,
+//! and framed by rekordcrate's independent parser), leaving every other section as it was. New
+//! files for a track without any hold its path and empty cue lists, and take the rest in
+//! rekordbox's order.
+
+use crate::common;
+
+use std::io::Cursor;
+use std::sync::OnceLock;
+
+use binrw::BinRead;
+use hegel::TestCase;
+use hegel::generators as gs;
+use locallibrary::anlz::{self, Bands, Beat, Mood, Phrase, PhraseKind, SongStructure, WaveformStyle};
+
+const ANALYSED: [&str; 2] = ["P016/0000875E", "P053/0001D21F"];
+
+/// One track's `.DAT`, `.EXT` and `.2EX`.
+type Analysis = [Vec; 3];
+
+/// The demo export's analysis files.
+fn real_files() -> &'static [Analysis] {
+ static FILES: OnceLock> = OnceLock::new();
+ FILES.get_or_init(|| {
+ ANALYSED
+ .iter()
+ .map(|dir| {
+ let d = common::fixtures().join("rekordcrate/demo_tracks/PIONEER/USBANLZ").join(dir);
+ ["DAT", "EXT", "2EX"].map(|ext| std::fs::read(d.join(format!("ANLZ0000.{ext}"))).unwrap())
+ })
+ .collect()
+ })
+}
+
+/// Each section's tag and bytes, in order.
+fn sections(bytes: &[u8]) -> Vec<([u8; 4], Vec)> {
+ let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
+ let (mut pos, end) = (be(4), be(8));
+ let mut out = Vec::new();
+ while pos + 12 <= end {
+ let total = be(pos + 8);
+ out.push((bytes[pos..pos + 4].try_into().unwrap(), bytes[pos..pos + total].to_vec()));
+ pos += total;
+ }
+ out
+}
+
+fn tags(bytes: &[u8]) -> Vec {
+ sections(bytes).iter().map(|(t, _)| String::from_utf8_lossy(t).into_owned()).collect()
+}
+
+/// The sections of `bytes` but those tagged `tag`.
+fn others(bytes: &[u8], tag: &[u8; 4]) -> Vec> {
+ sections(bytes).into_iter().filter(|(t, _)| t != tag).map(|(_, s)| s).collect()
+}
+
+/// rekordcrate frames the whole file: every section's header and the content it knows
+/// (asserting each waveform's length and count, a song structure's entries, a beat grid's
+/// count). Its beat grid's beats, if it has one.
+fn rekordcrate_beats(bytes: &[u8]) -> Option> {
+ use rekordcrate::anlz::{ANLZ, Content};
+ let file = ANLZ::read_be(&mut Cursor::new(bytes)).expect("rekordcrate reads the file");
+ file.sections.into_iter().find_map(|s| match s.content {
+ Content::BeatGrid(g) => Some(g.beats.iter().map(|b| (b.beat_number, b.tempo, b.time)).collect()),
+ _ => None,
+ })
+}
+
+/// rekordbox's files hold what they hold: writing it again changes nothing, byte for byte.
+#[test]
+fn rekordbox_files_rewrite_byte_for_byte() {
+ for [dat, ext, two] in real_files() {
+ assert_eq!(anlz::write_beat_grid(dat, &anlz::beat_grid(dat).unwrap()).unwrap(), *dat);
+ let phrases = anlz::song_structure(ext).unwrap();
+ assert!(phrases.is_some(), "the demo tracks have phrases (masked, as rekordbox 6 writes them)");
+ assert_eq!(anlz::write_song_structure(ext, phrases.as_ref()).unwrap(), *ext);
+ let mut written = Vec::new();
+ for file in [dat, ext, two] {
+ for tag in anlz::waveform_tags() {
+ let Some(columns) = anlz::waveform_entries(file, tag).unwrap() else { continue };
+ assert_eq!(
+ anlz::write_waveform(file, tag, &columns).unwrap(),
+ *file,
+ "{}",
+ String::from_utf8_lossy(tag)
+ );
+ written.push(String::from_utf8_lossy(tag).into_owned());
+ }
+ }
+ assert_eq!(written, ["PWAV", "PWV2", "PWV3", "PWV4", "PWV5", "PWV6", "PWV7"]);
+ // The extended grid comes out alone; the cue lists and the rest stay.
+ let without = anlz::without_extended_grid(ext).unwrap();
+ assert_eq!(others(&without, b"PQT2"), others(ext, b"PQT2"));
+ assert!(!tags(&without).contains(&"PQT2".to_string()));
+ assert_eq!(anlz::without_extended_grid(&without).unwrap(), without);
+ }
+}
+
+/// A base `.DAT` or `.EXT` to write into: one of rekordbox's, a minimal synthetic one, or a new
+/// one.
+#[hegel::test_helper]
+fn base(tc: &TestCase, ext: bool) -> Vec {
+ let which = tc.draw(gs::integers::().max_value(ANALYSED.len() + 1));
+ match real_files().get(which) {
+ Some(files) => files[usize::from(ext)].clone(),
+ None if which == ANALYSED.len() => common::synthetic_anlz("/Contents/a.mp3"),
+ None => {
+ let (dat, e) = anlz::new_analysis_files("/Contents/new.mp3").unwrap();
+ if ext { e } else { dat }
+ }
+ }
+}
+
+/// What a file says besides grids, phrases and waveforms, as the readers see it.
+fn content(bytes: &[u8]) -> String {
+ format!(
+ "{:?} {:?} {:?}",
+ anlz::read_path(bytes).unwrap(),
+ anlz::read_cue_points(bytes, None).unwrap().len(),
+ anlz::cues(bytes).unwrap()
+ )
+}
+
+/// A beat grid: in order, each beat 1-4 in its bar, at a tempo of hundredths of a BPM.
+#[hegel::test_helper]
+fn grid(tc: &TestCase) -> Vec {
+ let n = tc.draw(gs::integers::().min_value(1).max_value(300));
+ let mut time = tc.draw(gs::integers::().max_value(10_000));
+ (0..n)
+ .map(|i| {
+ time += tc.draw(gs::integers::().min_value(1).max_value(2_000));
+ let hundredths = tc.draw(gs::integers::().min_value(1));
+ Beat { beat_number: (i % 4) as u16 + 1, bpm: f64::from(hundredths) / 100.0, time_ms: time }
+ })
+ .collect()
+}
+
+/// A grid written reads back as given, as rekordcrate reads it too, and nothing else in the file
+/// changes; written again it stays as it is.
+#[hegel::test(test_cases = 200)]
+fn beat_grids_read_back_as_written(tc: TestCase) {
+ let dat = base(&tc, false);
+ let beats = grid(&tc);
+ let out = anlz::write_beat_grid(&dat, &beats).unwrap();
+ assert_eq!(anlz::beat_grid(&out).unwrap(), beats);
+ let crate_view: Vec<(u16, u16, u32)> =
+ beats.iter().map(|b| (b.beat_number, (b.bpm * 100.0).round() as u16, b.time_ms)).collect();
+ assert_eq!(rekordcrate_beats(&out), Some(crate_view));
+ assert_eq!(others(&out, b"PQTZ"), others(&dat, b"PQTZ"));
+ assert_eq!(content(&out), content(&dat));
+ assert_eq!(anlz::write_beat_grid(&out, &beats).unwrap(), out);
+ assert_eq!(u32::from_be_bytes(out[8..12].try_into().unwrap()) as usize, out.len());
+}
+
+/// A grid the format can't hold is refused, saying why.
+#[test]
+fn grids_that_cant_be_stored_are_refused() {
+ let dat = common::synthetic_anlz("/Contents/a.mp3");
+ let beat = |n: u16, bpm: f64, ms: u32| Beat { beat_number: n, bpm, time_ms: ms };
+ let refused = |beats: &[Beat]| anlz::write_beat_grid(&dat, beats).unwrap_err().to_string();
+ assert_eq!(refused(&[]), "a beat grid needs a beat");
+ assert_eq!(refused(&[beat(5, 120.0, 0)]), "beat 1 is beat 5 of its bar: 1-4");
+ assert_eq!(refused(&[beat(1, 700.0, 0)]), "beat 1's tempo, 700 BPM, isn't 0.01-655.35");
+ assert_eq!(refused(&[beat(1, f64::NAN, 0)]), "beat 1's tempo, NaN BPM, isn't 0.01-655.35");
+ assert_eq!(refused(&[beat(1, 120.0, 5), beat(2, 120.0, 5)]), "beat 2 isn't after the one before it");
+ // Tempos are kept to hundredths.
+ let out = anlz::write_beat_grid(&dat, &[beat(1, 128.004, 0)]).unwrap();
+ assert_eq!(anlz::beat_grid(&out).unwrap()[0].bpm, 128.0);
+}
+
+/// The kinds a mood names, each as rekordbox names it.
+fn kinds(mood: Mood) -> Vec {
+ use PhraseKind::*;
+ let mut k = vec![Intro];
+ match mood {
+ Mood::High => k.extend([Up, Down, Chorus, Outro, Unknown(4), Unknown(7)]),
+ Mood::Mid => k.extend((1..=6).map(Verse).chain([Bridge, Chorus, Outro, Unknown(11)])),
+ Mood::Low => k.extend([Verse(1), Verse(2), Bridge, Chorus, Outro, Unknown(0)]),
+ }
+ k
+}
+
+/// Phrases following one another from beat 1, each of a kind the mood names, some with fill-ins.
+#[hegel::test_helper]
+fn structure(tc: &TestCase) -> SongStructure {
+ let mood = tc.draw(gs::sampled_from(vec![Mood::High, Mood::Mid, Mood::Low]));
+ let n = tc.draw(gs::integers::().min_value(1).max_value(60));
+ let mut start = tc.draw(gs::integers::().min_value(1).max_value(40));
+ let phrases = (0..n)
+ .map(|_| {
+ let len = tc.draw(gs::integers::().min_value(1).max_value(64));
+ let end = start + len;
+ let fill = tc.draw(gs::optional(gs::integers::().min_value(1).max_value(end)));
+ let p = Phrase {
+ kind: tc.draw(gs::sampled_from(kinds(mood))),
+ start_beat: start,
+ end_beat: end,
+ fill_beat: fill,
+ };
+ start = end;
+ p
+ })
+ .collect();
+ SongStructure { mood, phrases }
+}
+
+/// Phrases written read back as given, masked as the file's were (new ones too, as rekordbox 6
+/// writes them) unless that would read as unmasked, framed as rekordcrate expects, and nothing
+/// else changes; written again they stay; taken out, the file is as it was without them.
+#[hegel::test(test_cases = 200)]
+fn phrases_read_back_as_written(tc: TestCase) {
+ let ext = base(&tc, true);
+ let s = structure(&tc);
+ let out = anlz::write_song_structure(&ext, Some(&s)).unwrap();
+ assert_eq!(anlz::song_structure(&out).unwrap(), Some(s.clone()));
+ rekordcrate_beats(&out);
+ assert_eq!(others(&out, b"PSSI"), others(&ext, b"PSSI"));
+ assert_eq!(content(&out), content(&ext));
+ assert_eq!(anlz::write_song_structure(&out, Some(&s)).unwrap(), out);
+ let pssi = sections(&out).into_iter().find(|(t, _)| t == b"PSSI").unwrap().1;
+ // Masking a mid track's mood with a count of 53 makes 20, which reads as unmasked.
+ let reads_unmasked = s.mood == Mood::Mid && (s.phrases.len() as u8).wrapping_add(0xCB) == 0;
+ assert_eq!(u16::from_be_bytes([pssi[18], pssi[19]]) > 20, !reads_unmasked);
+ let gone = anlz::write_song_structure(&out, None).unwrap();
+ assert_eq!(others(&gone, b"PSSI"), others(&ext, b"PSSI"));
+ assert_eq!(anlz::song_structure(&gone).unwrap(), None);
+}
+
+/// Changing one phrase keeps every other entry's bytes, flags rekordbox set included.
+#[test]
+fn changing_a_phrase_keeps_the_others_entries() {
+ let ext = &real_files()[1][1];
+ let mut s = anlz::song_structure(ext).unwrap().unwrap();
+ let unmasked = |b: &[u8]| {
+ let pssi = sections(b).into_iter().find(|(t, _)| t == b"PSSI").unwrap().1;
+ let count = u16::from_be_bytes([pssi[16], pssi[17]]);
+ let mask = [
+ 0xCB, 0xE1, 0xEE, 0xFA, 0xE5, 0xEE, 0xAD, 0xEE, 0xE9, 0xD2, 0xE9, 0xEB, 0xE1, 0xE9, 0xF3, 0xE8, 0xE9, 0xF4,
+ 0xE1u8,
+ ];
+ let body: Vec =
+ pssi[18..].iter().enumerate().map(|(i, b)| b ^ mask[i % 19].wrapping_add(count as u8)).collect();
+ body[14..].chunks(24).map(<[u8]>::to_vec).collect::>()
+ };
+ let before = unmasked(ext);
+ s.phrases[3].kind = PhraseKind::Chorus;
+ let after = unmasked(&anlz::write_song_structure(ext, Some(&s)).unwrap());
+ for (i, (a, b)) in before.iter().zip(&after).enumerate() {
+ assert_eq!(a == b, i != 3, "entry {i}");
+ }
+ assert_eq!(u16::from_be_bytes([after[3][4], after[3][5]]), 9, "a mid track's chorus");
+}
+
+/// Phrases a `PSSI` can't hold are refused, saying why.
+#[test]
+fn phrases_that_cant_be_stored_are_refused() {
+ let ext = common::synthetic_anlz("/Contents/a.mp3");
+ let phrase = |kind, start_beat, end_beat| Phrase { kind, start_beat, end_beat, fill_beat: None };
+ let refused = |mood, phrases: Vec| {
+ anlz::write_song_structure(&ext, Some(&SongStructure { mood, phrases })).unwrap_err().to_string()
+ };
+ assert_eq!(refused(Mood::Mid, vec![]), "phrases need a phrase");
+ let gap = vec![phrase(PhraseKind::Intro, 1, 17), phrase(PhraseKind::Outro, 20, 30)];
+ assert_eq!(refused(Mood::Mid, gap), "phrase 1 runs from beat 1 to 17: phrases follow one another from beat 1");
+ assert_eq!(
+ refused(Mood::Low, vec![phrase(PhraseKind::Intro, 0, 4)]),
+ "phrase 1 runs from beat 0 to 4: phrases follow one another from beat 1"
+ );
+ assert_eq!(
+ refused(Mood::High, vec![phrase(PhraseKind::Verse(1), 1, 4)]),
+ "phrase 1: a High track has no Verse 1 phrases"
+ );
+ assert_eq!(refused(Mood::Mid, vec![phrase(PhraseKind::Up, 1, 4)]), "phrase 1: a Mid track has no Up phrases");
+ assert_eq!(
+ refused(Mood::Low, vec![phrase(PhraseKind::Unknown(3), 1, 4)]),
+ "phrase 1: a Low track has no Phrase 3 phrases"
+ );
+ let fill = Phrase { fill_beat: Some(0), ..phrase(PhraseKind::Intro, 1, 4) };
+ assert_eq!(refused(Mood::Mid, vec![fill]), "phrase 1's fill-in starts on beat 0: beats count from 1");
+}
+
+/// A waveform tag's stored columns: whole columns of its size.
+#[hegel::test_helper]
+fn columns(tc: &TestCase, size: usize) -> Vec {
+ let n = tc.draw(gs::integers::().min_value(1).max_value(500));
+ tc.draw(gs::vecs(gs::integers::()).min_size(n * size).max_size(n * size))
+}
+
+/// Any tag's columns written read back as given, framed as rekordcrate expects (each count is
+/// its length), and nothing else changes; written again they stay as they are.
+#[hegel::test(test_cases = 200)]
+fn waveforms_read_back_as_written(tc: TestCase) {
+ let tag = *tc.draw(gs::sampled_from(anlz::waveform_tags().collect::>()));
+ let size = match &tag {
+ b"PWV4" => 6,
+ b"PWV5" => 2,
+ b"PWV6" | b"PWV7" => 3,
+ _ => 1,
+ };
+ let file = base(&tc, tc.draw(gs::booleans()));
+ let cols = columns(&tc, size);
+ let out = anlz::write_waveform(&file, &tag, &cols).unwrap();
+ assert_eq!(anlz::waveform_entries(&out, &tag).unwrap(), Some(cols.clone()));
+ rekordcrate_beats(&out);
+ assert_eq!(others(&out, &tag), others(&file, &tag));
+ assert_eq!(anlz::write_waveform(&out, &tag, &cols).unwrap(), out);
+ let short = anlz::write_waveform(&file, &tag, &cols[..cols.len() - 1]);
+ assert_eq!(short.is_err(), size > 1 || cols.len() == 1);
+}
+
+/// Bands, at 150 columns a second.
+#[hegel::test_helper]
+fn bands(tc: &TestCase) -> Bands {
+ let n = tc.draw(gs::integers::().min_value(1).max_value(3_000));
+ let loud = tc.draw(gs::integers::());
+ let band = || gs::integers::().max_value(loud);
+ Bands((0..n).map(|_| [tc.draw(band()), tc.draw(band()), tc.draw(band())]).collect())
+}
+
+/// Every tag but `PWV4` is worked out from bands: previews of rekordbox's widths, details as
+/// long as the bands; heights from the loudest band, whiteness and colours from the bands'
+/// shares, 3-band heights from each band. Written and read for drawing, the 3-band ones give
+/// the bands back (to their precision).
+#[hegel::test(test_cases = 100)]
+fn waveforms_are_worked_out_from_bands(tc: TestCase) {
+ let b = bands(&tc);
+ assert_eq!(anlz::waveform_from_bands(b"PWV4", &b).unwrap(), None);
+ let loudest = |c: &[u8; 3]| u32::from(c[0].max(c[1]).max(c[2]));
+ for (tag, n) in [(b"PWAV", 400), (b"PWV2", 100), (b"PWV6", 1200)] {
+ let made = anlz::waveform_from_bands(tag, &b).unwrap().unwrap();
+ let size = if tag == b"PWV6" { 3 } else { 1 };
+ assert_eq!(made.len(), n * size, "{}", String::from_utf8_lossy(tag));
+ let pooled = b.pooled(n);
+ if tag == b"PWV2" {
+ assert!(made.iter().zip(&pooled.0).all(|(m, c)| u32::from(*m) == loudest(c) * 15 / 255));
+ }
+ }
+ let blue = anlz::waveform_from_bands(b"PWV3", &b).unwrap().unwrap();
+ let color = anlz::waveform_from_bands(b"PWV5", &b).unwrap().unwrap();
+ assert_eq!((blue.len(), color.len()), (b.0.len(), 2 * b.0.len()));
+ for (i, c) in b.0.iter().enumerate() {
+ let top = loudest(c);
+ assert_eq!(u32::from(blue[i] & 0x1f), top * 31 / 255);
+ assert_eq!(u32::from(blue[i] >> 5), u32::from(c[2]) * 7 / top.max(1));
+ let v = u16::from_be_bytes([color[2 * i], color[2 * i + 1]]);
+ let rgb = [v >> 13, (v >> 10) & 7, (v >> 7) & 7];
+ assert_eq!(rgb.map(u32::from), c.map(|x| u32::from(x) * 7 / top.max(1)));
+ assert_eq!(u32::from((v >> 2) & 0x1f), top * 31 / 255);
+ assert_eq!(v & 3, 0);
+ }
+ let two = &real_files()[0][2];
+ let mut file = two.clone();
+ for tag in [b"PWV6", b"PWV7"] {
+ file = anlz::write_waveform(&file, tag, &anlz::waveform_from_bands(tag, &b).unwrap().unwrap()).unwrap();
+ }
+ let (preview, detail) = (anlz::preview(&file).unwrap().unwrap(), anlz::detail(&file).unwrap().unwrap());
+ assert_eq!((preview.style, detail.style), (WaveformStyle::ThreeBand, WaveformStyle::ThreeBand));
+ assert_eq!((preview.columns.len(), detail.columns.len(), detail.columns_per_sec), (1200, b.0.len(), Some(150.0)));
+ // Each band to 1/128 of its height: the detail keeps the top 7 bits of each.
+ let back = |c: &[u8; 3]| Bands(vec![c.map(|v| v & !1)]).waveform(None).columns[0];
+ for (drawn, c) in detail.columns.iter().zip(&b.0) {
+ assert_eq!(*drawn, back(c));
+ }
+}
+
+/// Bands at another rate are pooled to 150 columns a second, as long in time.
+#[test]
+fn bands_go_to_the_detail_rate() {
+ let b = Bands(vec![[1, 2, 3]; 100]);
+ assert_eq!(anlz::at_detail_rate(&b, 75.0).0.len(), 200);
+ assert_eq!(anlz::at_detail_rate(&b, 300.0).0.len(), 50);
+ assert_eq!(anlz::at_detail_rate(&Bands::default(), 150.0).0.len(), 1);
+}
+
+/// A waveform the format can't hold, or a tag that isn't a waveform's, is refused.
+#[test]
+fn waveforms_that_cant_be_stored_are_refused() {
+ let file = common::synthetic_anlz("/Contents/a.mp3");
+ let refused = |tag: &[u8; 4], cols: &[u8]| anlz::write_waveform(&file, tag, cols).unwrap_err().to_string();
+ assert_eq!(refused(b"PWV5", &[1, 2, 3]), "PWV5 takes whole columns of 2 bytes");
+ assert_eq!(refused(b"PWAV", &[]), "PWAV takes whole columns of 1 bytes");
+ assert_eq!(refused(b"PQTZ", &[1]), "PQTZ isn't a waveform tag");
+ assert!(anlz::waveform_entries(&file, b"PCOB").is_err());
+ // A file holding a tag twice is left alone.
+ let pwav = sections(&anlz::write_waveform(&file, b"PWAV", &[1]).unwrap()).pop().unwrap().1;
+ let mut twice = [file.clone(), pwav.clone(), pwav].concat();
+ let len = twice.len() as u32;
+ twice[8..12].copy_from_slice(&len.to_be_bytes());
+ let err = anlz::write_waveform(&twice, b"PWAV", &[2]).unwrap_err().to_string();
+ assert_eq!(err, "the analysis file holds two PWAV sections; left as it is");
+}
+
+/// New files hold the track's path and empty cue lists, as rekordbox writes for a track without
+/// cues, with the header rekordbox 6 writes; what's written into them goes in rekordbox's order.
+#[test]
+fn new_files_take_what_is_written_in_rekordbox_order() {
+ let (dat, ext) = anlz::new_analysis_files("/Contents/Ärtist/a track.mp3").unwrap();
+ for f in [&dat, &ext] {
+ assert_eq!(anlz::read_path(f).unwrap().as_deref(), Some("/Contents/Ärtist/a track.mp3"));
+ assert_eq!(f[12..28], [0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]);
+ assert!(anlz::read_cue_points(f, None).unwrap().is_empty());
+ rekordcrate_beats(f);
+ }
+ assert_eq!(tags(&dat), ["PPTH", "PCOB", "PCOB"]);
+ assert_eq!(tags(&ext), ["PPTH", "PCOB", "PCOB", "PCO2", "PCO2"]);
+ let b = Bands(vec![[200, 100, 50]; 600]);
+ let wave = |f: Vec, tag: &[u8; 4]| {
+ anlz::write_waveform(&f, tag, &anlz::waveform_from_bands(tag, &b).unwrap().unwrap()).unwrap()
+ };
+ let beats = [Beat { beat_number: 1, bpm: 120.0, time_ms: 0 }];
+ let dat = wave(wave(anlz::write_beat_grid(&dat, &beats).unwrap(), b"PWV2"), b"PWAV");
+ let phrases = SongStructure {
+ mood: Mood::Low,
+ phrases: vec![Phrase { kind: PhraseKind::Intro, start_beat: 1, end_beat: 2, fill_beat: None }],
+ };
+ let ext = anlz::write_song_structure(&wave(wave(ext, b"PWV5"), b"PWV3"), Some(&phrases)).unwrap();
+ assert_eq!(tags(&dat), ["PPTH", "PQTZ", "PWAV", "PWV2", "PCOB", "PCOB"]);
+ assert_eq!(tags(&ext), ["PPTH", "PWV3", "PCOB", "PCOB", "PCO2", "PCO2", "PWV5", "PSSI"]);
+ assert_eq!(rekordcrate_beats(&dat), Some(vec![(1, 12000, 0)]));
+ rekordcrate_beats(&ext);
+}
diff --git a/crates/loclib-model/src/analysis.rs b/crates/loclib-model/src/analysis.rs
index 4d403cdf..4fe29334 100644
--- a/crates/loclib-model/src/analysis.rs
+++ b/crates/loclib-model/src/analysis.rs
@@ -67,6 +67,62 @@ pub enum WaveformStyle {
Blue,
/// Colour by frequency content, nexus 2 and later (`PWV4`, `PWV5`).
Rgb,
+ /// Lows, mids and highs drawn over one another, the CDJ-3000's (`PWV6`, `PWV7`), and what
+ /// analyzer plugins give ([`Bands`]).
+ ThreeBand,
+}
+
+/// A waveform as loudness in three bands, each column's lows, mids and highs, 0-255: what an
+/// analyzer plugin works out, and what every kind of rekordbox waveform can be made from.
+/// Packed (a plugin's blob) it's three bytes a column in that order.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct Bands(pub Vec<[u8; 3]>);
+
+impl Bands {
+ pub fn pack(&self) -> Vec {
+ self.0.concat()
+ }
+
+ /// Packed bytes as columns, if they're whole ones.
+ pub fn unpack(bytes: &[u8]) -> Option {
+ bytes.len().is_multiple_of(3).then(|| Bands(bytes.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect()))
+ }
+
+ /// `n` columns spanning the same time (a preview's), each band the loudest of the columns
+ /// it covers. None makes silent ones; more than there are repeats them.
+ pub fn pooled(&self, n: usize) -> Bands {
+ let len = self.0.len();
+ if len == 0 {
+ return Bands(vec![[0; 3]; n]);
+ }
+ let column = |i: usize| {
+ let from = i * len / n;
+ let to = ((i + 1) * len).div_ceil(n).clamp(from + 1, len);
+ self.0[from..to].iter().fold([0u8; 3], |m, c| [m[0].max(c[0]), m[1].max(c[1]), m[2].max(c[2])])
+ };
+ Bands((0..n).map(column).collect())
+ }
+
+ /// As drawn: each column's two tallest bands, scaled as [`crate::tokens::bands::SCALES`]
+ /// says, in their colours, the tallest at the back (a column draws two layers).
+ pub fn waveform(&self, columns_per_sec: Option) -> Waveform {
+ use crate::tokens::bands::{HIGH, LOW, MID, SCALES};
+ let colors = [LOW, MID, HIGH];
+ let columns = self
+ .0
+ .iter()
+ .map(|c| {
+ let mut layers: [WaveLayer; 3] = std::array::from_fn(|i| WaveLayer {
+ height: f32::from(c[i]) / 255.0 * SCALES[i],
+ color: colors[i],
+ });
+ // Stable: of two alike, the lower band stays behind.
+ layers.sort_by(|a, b| b.height.total_cmp(&a.height));
+ WaveColumn { back: layers[0], front: layers[1] }
+ })
+ .collect();
+ Waveform { style: WaveformStyle::ThreeBand, columns_per_sec, columns }
+ }
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
diff --git a/crates/loclib-model/src/analysis/tests.rs b/crates/loclib-model/src/analysis/tests.rs
index 727bd7da..da115f13 100644
--- a/crates/loclib-model/src/analysis/tests.rs
+++ b/crates/loclib-model/src/analysis/tests.rs
@@ -89,6 +89,51 @@ fn analyses_serialise_as_their_schema_describes(tc: TestCase) {
assert_eq!(json["detail"], serde_json::Value::Null);
}
+/// Packed bands unpack to themselves, and only whole columns unpack; pooled, each band's loudest
+/// stays the loudest; drawn, a column is its two tallest bands, the taller behind.
+#[hegel::test(test_cases = 100)]
+fn bands_pack_pool_and_draw(tc: TestCase) {
+ let columns = tc.draw(gs::vecs(gs::tuples!(gs::integers(), gs::integers(), gs::integers())).max_size(40));
+ let bands = Bands(columns.into_iter().map(|(l, m, h)| [l, m, h]).collect());
+ let packed = bands.pack();
+ assert_eq!(packed.len(), 3 * bands.0.len());
+ assert_eq!(Bands::unpack(&packed), Some(bands.clone()));
+ let extra = tc.draw(gs::integers::().min_value(1).max_value(2));
+ assert_eq!(Bands::unpack(&[packed, vec![0; extra]].concat()), None);
+
+ let n = tc.draw(gs::integers::().min_value(1).max_value(60));
+ let pooled = bands.pooled(n);
+ assert_eq!(pooled.0.len(), n);
+ for band in 0..3 {
+ let loudest = |b: &Bands| b.0.iter().map(|c| c[band]).max().unwrap_or(0);
+ assert_eq!(loudest(&pooled), loudest(&bands), "band {band}");
+ }
+
+ let w = bands.waveform(Some(150.0));
+ assert_eq!((w.style, w.columns_per_sec, w.columns.len()), (WaveformStyle::ThreeBand, Some(150.0), bands.0.len()));
+ for (c, b) in w.columns.iter().zip(&bands.0) {
+ let mut scaled: Vec = (0..3).map(|i| f32::from(b[i]) / 255.0 * crate::tokens::bands::SCALES[i]).collect();
+ scaled.sort_by(|a, b| b.total_cmp(a));
+ assert_eq!((c.back.height, c.front.height), (scaled[0], scaled[1]));
+ assert!(c.back.height <= 1.0);
+ }
+}
+
+#[test]
+fn bands_pool_into_previews() {
+ let bands = Bands(vec![[1, 0, 0], [0, 9, 0], [0, 0, 3], [4, 4, 4]]);
+ assert_eq!(bands.pooled(2), Bands(vec![[1, 9, 0], [4, 4, 4]]));
+ assert_eq!(bands.pooled(8).0[..2], [[1, 0, 0], [1, 0, 0]]);
+ assert_eq!(Bands::default().pooled(3), Bands(vec![[0; 3]; 3]));
+ // The lows behind, the mids (as tall as the highs) in front.
+ let w = Bands(vec![[255, 0, 0]]).waveform(None);
+ assert_eq!(
+ (w.columns[0].back.color, w.columns[0].front.color),
+ (crate::tokens::bands::LOW, crate::tokens::bands::MID)
+ );
+ assert_eq!(w.columns[0].front.height, 0.0);
+}
+
/// Beats and cues read back as they're written, as a plugin reads them from the host.
#[test]
fn beats_and_cues_read_back() {
From 42a3a3d6a79044072b87a092bcf5631205311ab8 Mon Sep 17 00:00:00 2001
From: botandbang <337765928+botandbang@users.noreply.github.com>
Date: Tue, 20 Oct 2026 23:20:10 -0400
Subject: [PATCH 2/5] plugins: analyzers work out beat grids, phrases and
waveforms, drawn where a drive has none and written to its analysis files as
a journaled, undoable edit
---
apps/locallibrary/CHANGELOG.md | 10 +
apps/locallibrary/docs/commands.md | 2 +-
apps/locallibrary/docs/plugins.md | 19 +
apps/locallibrary/docs/rpc/locallibrary.d.ts | 202 ++++--
apps/locallibrary/docs/rpc/schema.json | 664 +++++++++++++-----
apps/locallibrary/docs/sidecar.md | 9 +
apps/locallibrary/src/plugin_cmd/analyze.rs | 76 +-
apps/locallibrary/src/plugins.rs | 1 +
apps/locallibrary/src/rpc/methods/plugins.rs | 2 +
.../src/rpc/methods/plugins/analysis.rs | 24 +
apps/locallibrary/src/tui/app.rs | 28 +-
apps/locallibrary/src/tui/app/props.rs | 3 +-
apps/locallibrary/src/tui/app/testing.rs | 1 +
apps/locallibrary/src/tui/app/tests.rs | 47 ++
apps/locallibrary/src/tui/ui.rs | 6 +-
apps/locallibrary/src/tui/ui/tests.rs | 1 +
.../tests/it/commands/analysis.rs | 47 +-
crates/locallibrary/src/analysis_edit.rs | 359 ++++++++++
crates/locallibrary/src/anlz.rs | 5 +
crates/locallibrary/src/anlz/grid.rs | 6 +-
crates/locallibrary/src/artwork.rs | 37 +-
crates/locallibrary/src/artwork/tests.rs | 2 +-
.../locallibrary/src/bin/test-plugin/main.rs | 8 +-
.../src/bin/test-plugin/plugin.rs | 9 +-
crates/locallibrary/src/cue_edit.rs | 9 +-
crates/locallibrary/src/edit.rs | 138 +++-
crates/locallibrary/src/fields.rs | 5 +
crates/locallibrary/src/fields/overlay.rs | 161 +++++
crates/locallibrary/src/lib.rs | 2 +
crates/locallibrary/src/op.rs | 9 +
crates/locallibrary/src/plugin.rs | 14 +-
crates/locallibrary/src/plugin/analysis.rs | 180 +++++
crates/locallibrary/src/plugin/analyzers.rs | 78 +-
crates/locallibrary/src/plugin/operations.rs | 38 +-
crates/locallibrary/src/profile/mod.rs | 10 +-
crates/locallibrary/src/query.rs | 5 +-
crates/locallibrary/src/staged.rs | 56 ++
crates/locallibrary/tests/it/analysis_edit.rs | 361 ++++++++++
crates/locallibrary/tests/it/main.rs | 1 +
crates/locallibrary/tests/it/plugins.rs | 1 +
.../locallibrary/tests/it/plugins/analysis.rs | 343 +++++++++
crates/loclib-plugin/README.md | 16 +-
crates/loclib-plugin/src/features.rs | 1 +
crates/loclib-plugin/src/hooks.rs | 1 +
crates/loclib-plugin/src/hooks/analysis.rs | 94 +++
crates/loclib-plugin/src/hooks/analyzers.rs | 22 +
crates/loclib-plugin/src/lib.rs | 1 +
crates/loclib-plugin/src/manifest.rs | 7 +-
crates/loclib-plugin/src/manifest/tests.rs | 89 ++-
crates/loclib-plugin/src/sdk/tests.rs | 10 +-
50 files changed, 2821 insertions(+), 399 deletions(-)
create mode 100644 apps/locallibrary/src/rpc/methods/plugins/analysis.rs
create mode 100644 crates/locallibrary/src/analysis_edit.rs
create mode 100644 crates/locallibrary/src/fields/overlay.rs
create mode 100644 crates/locallibrary/src/plugin/analysis.rs
create mode 100644 crates/locallibrary/src/staged.rs
create mode 100644 crates/locallibrary/tests/it/analysis_edit.rs
create mode 100644 crates/locallibrary/tests/it/plugins/analysis.rs
create mode 100644 crates/loclib-plugin/src/hooks/analysis.rs
diff --git a/apps/locallibrary/CHANGELOG.md b/apps/locallibrary/CHANGELOG.md
index c1d9ddc2..462f9eb5 100644
--- a/apps/locallibrary/CHANGELOG.md
+++ b/apps/locallibrary/CHANGELOG.md
@@ -259,6 +259,16 @@ Windows builds are experimental and untested on hardware (see Platforms in the R
- `coverart`: finds front covers on MusicBrainz and the Cover Art Archive for `art fetch`, an
album that could be several release groups offered as each of them. It's the only one that
goes online, and only when it's run.
+- **Beat grids, phrases and waveforms written to drives.** locallibrary writes rekordbox's beat
+ grid (`PQTZ`), phrases (`PSSI`, masked as rekordbox 6 writes them) and waveforms (`PWAV`,
+ `PWV2`, `PWV3`, `PWV5`, `PWV6`, `PWV7`) into a track's analysis files as a journaled, undoable
+ edit (`set_analysis`), each read back before it's written, rewriting rekordbox's own files byte
+ for byte where the data is the same, and reads 3-band waveforms (`PWV6`, `PWV7`) for a track
+ with no others. An analyzer plugin may work out a track's grid, phrases and waveform (fields of
+ type `beats`, `phrases`, `waveform`, feature `analysis`): kept beside its columns, drawn in the
+ player where the drive has none, and written with `analyze --apply grid,phrases,waveform` (the
+ sidecar's `plugins.apply_analysis.plan/apply`), a grid setting the track's BPM; operations plan
+ the same edit with `analysis:plan`. Checked against rekordbox's files, not yet on players.
- **The library is the unit.** Each drive holding a library is a clone of it, and its name doesn't
matter much: a library's name works where a drive does (but in commands about one particular
drive) and means its lead clone, the newest of its drives plugged in (the primary when they're
diff --git a/apps/locallibrary/docs/commands.md b/apps/locallibrary/docs/commands.md
index 9e80ba23..df6fe5b1 100644
--- a/apps/locallibrary/docs/commands.md
+++ b/apps/locallibrary/docs/commands.md
@@ -138,7 +138,7 @@ its drives.
| `locallibrary export [PLAYLIST] -o FILE` | A playlist, folder, history list or the whole library as M3U8, CSV or text |
| `locallibrary export [PLAYLIST] --format traktor\|serato\|engine -o DIR [--option KEY=VALUE]... [--dry-run]` | The library for other DJ software (the interop plugin's), written on this computer, see [Other DJ software](other-software.md#other-dj-software) |
| `locallibrary export [PLAYLIST] --format FORMAT -o PATH [--option KEY=VALUE]... [--dry-run] [--replace]` | A format a plugin adds; overwriting a file is asked about first unless `--replace`, see [Plugins](plugins.md) |
-| `locallibrary analyze [] [--plugin ID] [--analyzer NAME] [--track ID]... [--apply key,bpm [--overwrite] [--min-confidence N] [--dry-run]]` | Run plugins' analyzers on a library's tracks, once each; `track` shows what they worked out as `PLUGIN.FIELD` (`sound.bpm`); `--apply` then writes the analyzer's keys and BPMs to the tracks as an edit, see [Plugins](plugins.md) |
+| `locallibrary analyze [] [--plugin ID] [--analyzer NAME] [--track ID]... [--apply key,bpm\|grid,phrases,waveform [--overwrite] [--min-confidence N] [--dry-run]]` | Run plugins' analyzers on a library's tracks, once each; `track` shows what they worked out as `PLUGIN.FIELD` (`sound.bpm`); `--apply` then writes the analyzer's keys and BPMs to the tracks, or its beat grids, phrases and waveforms into their analysis files, as an edit, see [Plugins](plugins.md) |
| `locallibrary xml [] -o rekordbox.xml [--only PLAYLIST]` | Export as rekordbox XML (`--only`: just those playlists or folders and their tracks) |
**Editing**
diff --git a/apps/locallibrary/docs/plugins.md b/apps/locallibrary/docs/plugins.md
index b59c00d3..b976263c 100644
--- a/apps/locallibrary/docs/plugins.md
+++ b/apps/locallibrary/docs/plugins.md
@@ -44,6 +44,25 @@ tracks, the playlist, the drive) starting as it (see [the player](player.md)). T
`plugins.*` methods (see [Building a GUI](sidecar.md)). Each kind is below, in a section of its
own.
+## Analysis
+
+An analyzer may also work out a track's beat grid, phrases and waveform (fields of type `beats`,
+`phrases` and `waveform`). They're not columns: locallibrary keeps them in the store beside the
+analyzer's other values, and the player draws them for a track whose drive has none, marked as not
+on the drive. `locallibrary analyze [LIBRARY] --apply grid,phrases,waveform` writes them into the
+tracks' analysis files, planned and confirmed as any edit is (`undo` takes it back, and `edits
+replay` makes it again after rekordbox exports): only where a track has none, unless
+`--overwrite`. A grid sets the track's BPM too, and its cues stay where they are (the plan says
+how many sat on beats of the old grid). Phrases count the analyzer's own beats, so they're written
+only with its grid, or onto the same one. A track rekordbox never analysed has no analysis files
+to take them, and a waveform can't go where rekordbox's colour waveforms are (locallibrary can't
+work out every byte of those). An operation can plan the same edit (`set_analysis`), with the
+`analysis:plan` capability.
+
+What locallibrary writes reads back as given, and rewriting what rekordbox wrote leaves its files
+byte for byte as they were, but that's checked against rekordbox's files, not on players: try a
+drive on yours before you rely on it.
+
## Analyzers
`locallibrary analyze [LIBRARY] [--plugin ID] [--analyzer NAME]` runs them, which works out each
diff --git a/apps/locallibrary/docs/rpc/locallibrary.d.ts b/apps/locallibrary/docs/rpc/locallibrary.d.ts
index c91dc392..17805dd5 100644
--- a/apps/locallibrary/docs/rpc/locallibrary.d.ts
+++ b/apps/locallibrary/docs/rpc/locallibrary.d.ts
@@ -662,6 +662,21 @@ export type Edit =
tracks: TrackSelector;
image: ImageSource;
op: "set_artwork";
+ }
+ /**
+ * Write a beat grid, phrases or a waveform into the analysis files of the track of this
+ * audio file (a device path, see [`crate::analysis_edit`]). Planned, a grid brings the
+ * track's BPM with it (an `edit_tracks`); the journal records a waveform as staged on the
+ * drive.
+ */
+ | {
+ track: string;
+ /** The beat grid: each beat's number in its bar (1-4), the tempo there and its time. */
+ beats?: Beat[] | null;
+ /** The phrases, named as the track's mood names them. */
+ phrases?: SongStructure | null;
+ waveform?: WaveSource | null;
+ op: "set_analysis";
};
/**
@@ -853,6 +868,61 @@ export type ImageSource =
/** A BLAKE3 hash, as 64 lowercase hex characters. */
export type Hash = string;
+export type Beat = {
+ /** Position in the bar, 1-4. */
+ beat_number: number;
+ bpm: number;
+ time_ms: number;
+};
+
+/** rekordbox's phrase analysis (`PSSI`, in `.EXT`). */
+export type SongStructure = {
+ mood: Mood;
+ phrases: Phrase[];
+};
+
+/** rekordbox's overall classification of a track, which decides how phrases are named. */
+export type Mood = "high" | "mid" | "low";
+
+export type Phrase = {
+ kind: PhraseKind;
+ /** 1-based beat numbers into the beat grid; the phrase runs up to (not including) `end_beat`. */
+ start_beat: number;
+ end_beat: number;
+ /** Beat where a fill-in leads into the next phrase. */
+ fill_beat?: number | null;
+};
+
+export type PhraseKind =
+ | ("intro" | "bridge" | "chorus" | "outro")
+ /** Build-ups, in high-mood tracks. */
+ | "up"
+ /** Breakdowns, in high-mood tracks. */
+ | "down"
+ | {
+ verse: number;
+ }
+ | {
+ unknown: number;
+ };
+
+/**
+ * A waveform an analysis edit writes: three bands, each column's lows, mids and highs (0-255),
+ * 150 columns a second ([`Bands`]), from which each of the track's waveform tags is worked out.
+ */
+export type WaveSource =
+ /** The bands packed (three bytes a column), in base64. */
+ | {
+ base64: string;
+ }
+ /**
+ * Staged on the drive (`.locallibrary/waveforms/.bands`), by its hash: how the journal
+ * records the others.
+ */
+ | {
+ staged: Hash;
+ };
+
export type ClearEdits = {
drive: string;
/** Only entries recorded before this time. */
@@ -2166,6 +2236,29 @@ export type SetPluginSetting = {
value?: unknown;
};
+/** What to write to a drive's tracks' analysis files, from what one analyzer kept. */
+export type ApplyAnalysis = {
+ plugin: string;
+ analyzer: string;
+ /** The library or drive whose tracks to write to. */
+ drive: string;
+ /** What to write, which the analyzer must work out: `grid`, `phrases`, `waveform`. */
+ parts: AnalysisPart[];
+ /** Only these tracks, by id [default: every one]. */
+ tracks?: number[] | null;
+ /** Also over what a track's analysis files have: without it, only where they have none. */
+ overwrite?: boolean;
+};
+
+/** A part of a track's analysis an analyzer can work out. */
+export type AnalysisPart =
+ /** The beat grid (a `beats` field). */
+ | "grid"
+ /** The phrases (a `phrases` field). */
+ | "phrases"
+ /** The waveform (a `waveform` field). */
+ | "waveform";
+
export type RunPluginCommand = {
plugin: string;
command: string;
@@ -2864,13 +2957,6 @@ export type AnalysisView = {
structure: SongStructure | null;
};
-export type Beat = {
- /** Position in the bar, 1-4. */
- beat_number: number;
- bpm: number;
- time_ms: number;
-};
-
export type Cue = {
/** `None` for memory cues; `Some(0)` = hot cue A, `Some(1)` = B, ... */
hot_cue: number | null;
@@ -2951,37 +3037,27 @@ export type PackedLayer = {
colors: string;
};
-/** rekordbox's phrase analysis (`PSSI`, in `.EXT`). */
-export type SongStructure = {
- mood: Mood;
- phrases: Phrase[];
-};
-
-/** rekordbox's overall classification of a track, which decides how phrases are named. */
-export type Mood = "high" | "mid" | "low";
-
-export type Phrase = {
- kind: PhraseKind;
- /** 1-based beat numbers into the beat grid; the phrase runs up to (not including) `end_beat`. */
- start_beat: number;
- end_beat: number;
- /** Beat where a fill-in leads into the next phrase. */
- fill_beat: number | null;
+/**
+ * Something a front end can offer to resolve an error: call a method, or send the request that
+ * failed again with some parameters added (`method` absent), or again with administrator rights.
+ */
+export type Action = {
+ /** What a button offering it says. */
+ label: string;
+ /**
+ * The RPC method to call (`pull`, `sync.plan`). Absent: the request that failed, again,
+ * with `params` added to its own.
+ */
+ method?: string | null;
+ /**
+ * The parameters: the method's, or the ones to add to the request that failed. `null`
+ * means a value the user has to give (a name, a path).
+ */
+ params: { [key: string]: unknown };
+ /** Run it with administrator rights (restart the sidecar elevated first). */
+ elevate?: boolean;
};
-export type PhraseKind =
- | ("intro" | "bridge" | "chorus" | "outro")
- /** Build-ups, in high-mood tracks. */
- | "up"
- /** Breakdowns, in high-mood tracks. */
- | "down"
- | {
- verse: number;
- }
- | {
- unknown: number;
- };
-
export type TracksAnalysis = {
/** In the order asked for. */
tracks: AnalysedTrack[];
@@ -3144,25 +3220,13 @@ export type DiffEntry = {
position: number;
};
-/**
- * Something a front end can offer to resolve an error: call a method, or send the request that
- * failed again with some parameters added (`method` absent), or again with administrator rights.
- */
-export type Action = {
- /** What a button offering it says. */
- label: string;
- /**
- * The RPC method to call (`pull`, `sync.plan`). Absent: the request that failed, again,
- * with `params` added to its own.
- */
- method?: string | null;
- /**
- * The parameters: the method's, or the ones to add to the request that failed. `null`
- * means a value the user has to give (a name, a path).
- */
- params: { [key: string]: unknown };
- /** Run it with administrator rights (restart the sidecar elevated first). */
- elevate?: boolean;
+export type PhraseOutput = {
+ kind: PhraseKind;
+ /** 1-based beat numbers into the beat grid; the phrase runs up to (not including) `end_beat`. */
+ start_beat: number;
+ end_beat: number;
+ /** Beat where a fill-in leads into the next phrase. */
+ fill_beat: number | null;
};
/** What putting a backup back would change. */
@@ -4257,6 +4321,21 @@ export type EditOutput =
tracks: TrackSelector;
image: ImageSource;
op: "set_artwork";
+ }
+ /**
+ * Write a beat grid, phrases or a waveform into the analysis files of the track of this
+ * audio file (a device path, see [`crate::analysis_edit`]). Planned, a grid brings the
+ * track's BPM with it (an `edit_tracks`); the journal records a waveform as staged on the
+ * drive.
+ */
+ | {
+ track: string;
+ /** The beat grid: each beat's number in its bar (1-4), the tempo there and its time. */
+ beats?: Beat[] | null;
+ /** The phrases, named as the track's mood names them. */
+ phrases?: SongStructure | null;
+ waveform?: WaveSource | null;
+ op: "set_analysis";
};
/** A track none of the players play that packing leaves as it is. */
@@ -7585,7 +7664,14 @@ export type Analyzer = {
roles?: { [key: string]: string };
};
-export type FieldType = "number" | "integer" | "string" | "boolean" | "string[]";
+export type FieldType =
+ | ("number" | "integer" | "string" | "boolean" | "string[]")
+ /** A beat grid (feature `analysis`, see [`crate::Output`]). */
+ | "beats"
+ /** A song structure: the mood and the phrases (feature `analysis`). */
+ | "phrases"
+ /** A waveform in three bands, in a blob (feature `analysis`). */
+ | "waveform";
/** Run with `export/write`. */
export type Exporter = {
@@ -8761,6 +8847,10 @@ export interface Methods {
"plugins.settings": { params: PluginRef; result: Settings };
/** immediate method */
"plugins.settings.set": { params: SetPluginSetting & WriteOptions; result: Settings };
+ /** plan method; paired with `plugins.apply_analysis.apply`; long running: sends `progress`, can be cancelled; can delete or overwrite something */
+ "plugins.apply_analysis.plan": { params: ApplyAnalysis & WriteOptions; result: EditPlan & PlanFields };
+ /** apply method; paired with `plugins.apply_analysis.plan`; long running: sends `progress`, can be cancelled; can delete or overwrite something */
+ "plugins.apply_analysis.apply": { params: ApplyPlan | (ApplyAnalysis & WriteOptions); result: EditReport };
/** read method; long running: sends `progress`, can be cancelled */
"plugins.run": { params: RunPluginCommand; result: CommandOutput };
/** plan method; paired with `plugins.op.apply`; long running: sends `progress`, can be cancelled; can delete or overwrite something */
diff --git a/apps/locallibrary/docs/rpc/schema.json b/apps/locallibrary/docs/rpc/schema.json
index fa7b5078..1b622d23 100644
--- a/apps/locallibrary/docs/rpc/schema.json
+++ b/apps/locallibrary/docs/rpc/schema.json
@@ -4953,6 +4953,64 @@
"$ref": "#/$defs/Settings"
}
},
+ {
+ "name": "plugins.apply_analysis.plan",
+ "aliases": [],
+ "kind": "plan",
+ "pair": "plugins.apply_analysis.apply",
+ "operation": "plugins.apply_analysis",
+ "long_running": true,
+ "destructive": true,
+ "params": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/ApplyAnalysis"
+ },
+ {
+ "$ref": "#/$defs/WriteOptions"
+ }
+ ]
+ },
+ "result": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/EditPlan"
+ },
+ {
+ "$ref": "#/$defs/PlanFields"
+ }
+ ]
+ }
+ },
+ {
+ "name": "plugins.apply_analysis.apply",
+ "aliases": [],
+ "kind": "apply",
+ "pair": "plugins.apply_analysis.plan",
+ "operation": "plugins.apply_analysis",
+ "long_running": true,
+ "destructive": true,
+ "params": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/ApplyPlan"
+ },
+ {
+ "allOf": [
+ {
+ "$ref": "#/$defs/ApplyAnalysis"
+ },
+ {
+ "$ref": "#/$defs/WriteOptions"
+ }
+ ]
+ }
+ ]
+ },
+ "result": {
+ "$ref": "#/$defs/EditReport"
+ }
+ },
{
"name": "plugins.run",
"aliases": [],
@@ -7002,6 +7060,54 @@
"image"
],
"description": "Give tracks this artwork, scaled to the sizes players show (see [`crate::artwork`]). The\njournal records the tracks by file and the image as staged on the drive."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "track": {
+ "type": "string"
+ },
+ "beats": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "$ref": "#/$defs/Beat"
+ },
+ "description": "The beat grid: each beat's number in its bar (1-4), the tempo there and its time."
+ },
+ "phrases": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/SongStructure"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The phrases, named as the track's mood names them."
+ },
+ "waveform": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/WaveSource"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "op": {
+ "type": "string",
+ "const": "set_analysis"
+ }
+ },
+ "required": [
+ "op",
+ "track"
+ ],
+ "description": "Write a beat grid, phrases or a waveform into the analysis files of the track of this\naudio file (a device path, see [`crate::analysis_edit`]). Planned, a grid brings the\ntrack's BPM with it (an `edit_tracks`); the journal records a waveform as staged on the\ndrive."
}
],
"description": "One change to a drive's library. Playlists are given as paths (`Folder/Name`) or unique\nnames; positions in a playlist count from 1."
@@ -7637,6 +7743,180 @@
"description": "A BLAKE3 hash, as 64 lowercase hex characters.",
"pattern": "^[0-9a-f]{64}$"
},
+ "Beat": {
+ "type": "object",
+ "properties": {
+ "beat_number": {
+ "type": "integer",
+ "format": "uint16",
+ "minimum": 0,
+ "maximum": 65535,
+ "description": "Position in the bar, 1-4."
+ },
+ "bpm": {
+ "type": "number",
+ "format": "double"
+ },
+ "time_ms": {
+ "type": "integer",
+ "format": "uint32",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "beat_number",
+ "bpm",
+ "time_ms"
+ ]
+ },
+ "SongStructure": {
+ "type": "object",
+ "properties": {
+ "mood": {
+ "$ref": "#/$defs/Mood"
+ },
+ "phrases": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/Phrase"
+ }
+ }
+ },
+ "required": [
+ "mood",
+ "phrases"
+ ],
+ "description": "rekordbox's phrase analysis (`PSSI`, in `.EXT`)."
+ },
+ "Mood": {
+ "type": "string",
+ "enum": [
+ "high",
+ "mid",
+ "low"
+ ],
+ "description": "rekordbox's overall classification of a track, which decides how phrases are named."
+ },
+ "Phrase": {
+ "type": "object",
+ "properties": {
+ "kind": {
+ "$ref": "#/$defs/PhraseKind"
+ },
+ "start_beat": {
+ "type": "integer",
+ "format": "uint16",
+ "minimum": 0,
+ "maximum": 65535,
+ "description": "1-based beat numbers into the beat grid; the phrase runs up to (not including) `end_beat`."
+ },
+ "end_beat": {
+ "type": "integer",
+ "format": "uint16",
+ "minimum": 0,
+ "maximum": 65535
+ },
+ "fill_beat": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "format": "uint16",
+ "minimum": 0,
+ "maximum": 65535,
+ "description": "Beat where a fill-in leads into the next phrase."
+ }
+ },
+ "required": [
+ "kind",
+ "start_beat",
+ "end_beat"
+ ]
+ },
+ "PhraseKind": {
+ "oneOf": [
+ {
+ "type": "string",
+ "enum": [
+ "intro",
+ "bridge",
+ "chorus",
+ "outro"
+ ]
+ },
+ {
+ "type": "string",
+ "const": "up",
+ "description": "Build-ups, in high-mood tracks."
+ },
+ {
+ "type": "string",
+ "const": "down",
+ "description": "Breakdowns, in high-mood tracks."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "verse": {
+ "type": "integer",
+ "format": "uint8",
+ "minimum": 0,
+ "maximum": 255
+ }
+ },
+ "required": [
+ "verse"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "unknown": {
+ "type": "integer",
+ "format": "uint16",
+ "minimum": 0,
+ "maximum": 65535
+ }
+ },
+ "required": [
+ "unknown"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "WaveSource": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "base64": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "base64"
+ ],
+ "additionalProperties": false,
+ "description": "The bands packed (three bytes a column), in base64."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "staged": {
+ "$ref": "#/$defs/Hash"
+ }
+ },
+ "required": [
+ "staged"
+ ],
+ "additionalProperties": false,
+ "description": "Staged on the drive (`.locallibrary/waveforms/.bands`), by its hash: how the journal\nrecords the others."
+ }
+ ],
+ "description": "A waveform an analysis edit writes: three bands, each column's lows, mids and highs (0-255),\n150 columns a second ([`Bands`]), from which each of the track's waveform tags is worked out."
+ },
"ClearEdits": {
"type": "object",
"properties": {
@@ -11135,6 +11415,73 @@
"key"
]
},
+ "ApplyAnalysis": {
+ "type": "object",
+ "properties": {
+ "plugin": {
+ "type": "string"
+ },
+ "analyzer": {
+ "type": "string"
+ },
+ "drive": {
+ "type": "string",
+ "description": "The library or drive whose tracks to write to."
+ },
+ "parts": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/AnalysisPart"
+ },
+ "description": "What to write, which the analyzer must work out: `grid`, `phrases`, `waveform`."
+ },
+ "tracks": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "type": "integer",
+ "format": "uint32",
+ "minimum": 0
+ },
+ "description": "Only these tracks, by id [default: every one].",
+ "default": null
+ },
+ "overwrite": {
+ "type": "boolean",
+ "description": "Also over what a track's analysis files have: without it, only where they have none.",
+ "default": false
+ }
+ },
+ "required": [
+ "plugin",
+ "analyzer",
+ "drive",
+ "parts"
+ ],
+ "description": "What to write to a drive's tracks' analysis files, from what one analyzer kept."
+ },
+ "AnalysisPart": {
+ "oneOf": [
+ {
+ "type": "string",
+ "const": "grid",
+ "description": "The beat grid (a `beats` field)."
+ },
+ {
+ "type": "string",
+ "const": "phrases",
+ "description": "The phrases (a `phrases` field)."
+ },
+ {
+ "type": "string",
+ "const": "waveform",
+ "description": "The waveform (a `waveform` field)."
+ }
+ ],
+ "description": "A part of a track's analysis an analyzer can work out."
+ },
"RunPluginCommand": {
"type": "object",
"properties": {
@@ -13352,51 +13699,25 @@
}
]
},
- "structure": {
- "anyOf": [
- {
- "$ref": "#/$defs/SongStructure"
- },
- {
- "type": "null"
- }
- ]
- }
- },
- "required": [
- "beats",
- "cues",
- "preview",
- "detail",
- "structure"
- ],
- "description": "[`TrackAnalysis`] with only the waveforms asked for, in the encoding asked for. With the\ndefaults it's the same as [`TrackAnalysis`]."
- },
- "Beat": {
- "type": "object",
- "properties": {
- "beat_number": {
- "type": "integer",
- "format": "uint16",
- "minimum": 0,
- "maximum": 65535,
- "description": "Position in the bar, 1-4."
- },
- "bpm": {
- "type": "number",
- "format": "double"
- },
- "time_ms": {
- "type": "integer",
- "format": "uint32",
- "minimum": 0
+ "structure": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/SongStructure"
+ },
+ {
+ "type": "null"
+ }
+ ]
}
},
"required": [
- "beat_number",
- "bpm",
- "time_ms"
- ]
+ "beats",
+ "cues",
+ "preview",
+ "detail",
+ "structure"
+ ],
+ "description": "[`TrackAnalysis`] with only the waveforms asked for, in the encoding asked for. With the\ndefaults it's the same as [`TrackAnalysis`]."
},
"Cue": {
"type": "object",
@@ -13657,123 +13978,36 @@
],
"description": "One layer of a [`PackedWaveform`]."
},
- "SongStructure": {
- "type": "object",
- "properties": {
- "mood": {
- "$ref": "#/$defs/Mood"
- },
- "phrases": {
- "type": "array",
- "items": {
- "$ref": "#/$defs/Phrase"
- }
- }
- },
- "required": [
- "mood",
- "phrases"
- ],
- "description": "rekordbox's phrase analysis (`PSSI`, in `.EXT`)."
- },
- "Mood": {
- "type": "string",
- "enum": [
- "high",
- "mid",
- "low"
- ],
- "description": "rekordbox's overall classification of a track, which decides how phrases are named."
- },
- "Phrase": {
+ "Action": {
"type": "object",
"properties": {
- "kind": {
- "$ref": "#/$defs/PhraseKind"
- },
- "start_beat": {
- "type": "integer",
- "format": "uint16",
- "minimum": 0,
- "maximum": 65535,
- "description": "1-based beat numbers into the beat grid; the phrase runs up to (not including) `end_beat`."
- },
- "end_beat": {
- "type": "integer",
- "format": "uint16",
- "minimum": 0,
- "maximum": 65535
+ "label": {
+ "type": "string",
+ "description": "What a button offering it says."
},
- "fill_beat": {
+ "method": {
"type": [
- "integer",
+ "string",
"null"
],
- "format": "uint16",
- "minimum": 0,
- "maximum": 65535,
- "description": "Beat where a fill-in leads into the next phrase."
- }
- },
- "required": [
- "kind",
- "start_beat",
- "end_beat",
- "fill_beat"
- ]
- },
- "PhraseKind": {
- "oneOf": [
- {
- "type": "string",
- "enum": [
- "intro",
- "bridge",
- "chorus",
- "outro"
- ]
- },
- {
- "type": "string",
- "const": "up",
- "description": "Build-ups, in high-mood tracks."
- },
- {
- "type": "string",
- "const": "down",
- "description": "Breakdowns, in high-mood tracks."
+ "description": "The RPC method to call (`pull`, `sync.plan`). Absent: the request that failed, again,\nwith `params` added to its own."
},
- {
+ "params": {
"type": "object",
- "properties": {
- "verse": {
- "type": "integer",
- "format": "uint8",
- "minimum": 0,
- "maximum": 255
- }
- },
- "required": [
- "verse"
- ],
- "additionalProperties": false
+ "additionalProperties": true,
+ "description": "The parameters: the method's, or the ones to add to the request that failed. `null`\nmeans a value the user has to give (a name, a path).",
+ "default": {}
},
- {
- "type": "object",
- "properties": {
- "unknown": {
- "type": "integer",
- "format": "uint16",
- "minimum": 0,
- "maximum": 65535
- }
- },
- "required": [
- "unknown"
- ],
- "additionalProperties": false
+ "elevate": {
+ "type": "boolean",
+ "description": "Run it with administrator rights (restart the sidecar elevated first)."
}
- ]
+ },
+ "required": [
+ "label",
+ "params"
+ ],
+ "description": "Something a front end can offer to resolve an error: call a method, or send the request that\nfailed again with some parameters added (`method` absent), or again with administrator rights."
},
"TracksAnalysis": {
"type": "object",
@@ -14313,36 +14547,42 @@
"position"
]
},
- "Action": {
+ "PhraseOutput": {
"type": "object",
"properties": {
- "label": {
- "type": "string",
- "description": "What a button offering it says."
+ "kind": {
+ "$ref": "#/$defs/PhraseKind"
},
- "method": {
+ "start_beat": {
+ "type": "integer",
+ "format": "uint16",
+ "minimum": 0,
+ "maximum": 65535,
+ "description": "1-based beat numbers into the beat grid; the phrase runs up to (not including) `end_beat`."
+ },
+ "end_beat": {
+ "type": "integer",
+ "format": "uint16",
+ "minimum": 0,
+ "maximum": 65535
+ },
+ "fill_beat": {
"type": [
- "string",
+ "integer",
"null"
],
- "description": "The RPC method to call (`pull`, `sync.plan`). Absent: the request that failed, again,\nwith `params` added to its own."
- },
- "params": {
- "type": "object",
- "additionalProperties": true,
- "description": "The parameters: the method's, or the ones to add to the request that failed. `null`\nmeans a value the user has to give (a name, a path).",
- "default": {}
- },
- "elevate": {
- "type": "boolean",
- "description": "Run it with administrator rights (restart the sidecar elevated first)."
+ "format": "uint16",
+ "minimum": 0,
+ "maximum": 65535,
+ "description": "Beat where a fill-in leads into the next phrase."
}
},
"required": [
- "label",
- "params"
- ],
- "description": "Something a front end can offer to resolve an error: call a method, or send the request that\nfailed again with some parameters added (`method` absent), or again with administrator rights."
+ "kind",
+ "start_beat",
+ "end_beat",
+ "fill_beat"
+ ]
},
"UndoPlan": {
"type": "object",
@@ -18122,6 +18362,54 @@
"image"
],
"description": "Give tracks this artwork, scaled to the sizes players show (see [`crate::artwork`]). The\njournal records the tracks by file and the image as staged on the drive."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "track": {
+ "type": "string"
+ },
+ "beats": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "items": {
+ "$ref": "#/$defs/Beat"
+ },
+ "description": "The beat grid: each beat's number in its bar (1-4), the tempo there and its time."
+ },
+ "phrases": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/SongStructure"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The phrases, named as the track's mood names them."
+ },
+ "waveform": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/WaveSource"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "op": {
+ "type": "string",
+ "const": "set_analysis"
+ }
+ },
+ "required": [
+ "op",
+ "track"
+ ],
+ "description": "Write a beat grid, phrases or a waveform into the analysis files of the track of this\naudio file (a device path, see [`crate::analysis_edit`]). Planned, a grid brings the\ntrack's BPM with it (an `edit_tracks`); the journal records a waveform as staged on the\ndrive."
}
],
"description": "One change to a drive's library. Playlists are given as paths (`Folder/Name`) or unique\nnames; positions in a playlist count from 1."
@@ -29159,7 +29447,8 @@
"library:delete",
"tags:plan",
"cues:plan",
- "artwork:plan"
+ "artwork:plan",
+ "analysis:plan"
]
},
{
@@ -29466,13 +29755,32 @@
"description": "Run with `track/analyze`; its fields become columns."
},
"FieldType": {
- "type": "string",
- "enum": [
- "number",
- "integer",
- "string",
- "boolean",
- "string[]"
+ "oneOf": [
+ {
+ "type": "string",
+ "enum": [
+ "number",
+ "integer",
+ "string",
+ "boolean",
+ "string[]"
+ ]
+ },
+ {
+ "type": "string",
+ "const": "beats",
+ "description": "A beat grid (feature `analysis`, see [`crate::Output`])."
+ },
+ {
+ "type": "string",
+ "const": "phrases",
+ "description": "A song structure: the mood and the phrases (feature `analysis`)."
+ },
+ {
+ "type": "string",
+ "const": "waveform",
+ "description": "A waveform in three bands, in a blob (feature `analysis`)."
+ }
]
},
"Exporter": {
diff --git a/apps/locallibrary/docs/sidecar.md b/apps/locallibrary/docs/sidecar.md
index fbbecf6b..d5623549 100644
--- a/apps/locallibrary/docs/sidecar.md
+++ b/apps/locallibrary/docs/sidecar.md
@@ -372,6 +372,15 @@ plugin starts when it's first used and stops once it's idle a while, and when th
plugin's errors come with locallibrary's kinds (`not_found`, `invalid`, `io`...), the plugin named
in the message. Each kind of thing plugins add is below, in a section of its own.
+### Analysis
+
+`plugins.apply_analysis.plan {plugin, analyzer, drive, parts: ["grid" | "phrases" | "waveform"],
+tracks?, overwrite?}` plans writing the beat grid, phrases and waveform an analyzer worked out into
+the tracks' analysis files (each track's `set_analysis` edit, a grid with its `edit_tracks` BPM),
+applied by `plugins.apply_analysis.apply {plan_id}`; tracks it can't write to are left out with a
+warning. An operation's `set_analysis` edit (`{op, track, beats?, phrases?, waveform?: {blob}}`,
+the waveform a file in its scratch folder) needs `analysis:plan`.
+
### Analyzers
`plugins.analyze {plugin, analyzer, drive, tracks?}` runs an analyzer on the drive's tracks (those
diff --git a/apps/locallibrary/src/plugin_cmd/analyze.rs b/apps/locallibrary/src/plugin_cmd/analyze.rs
index b0dfe648..f1e02fb2 100644
--- a/apps/locallibrary/src/plugin_cmd/analyze.rs
+++ b/apps/locallibrary/src/plugin_cmd/analyze.rs
@@ -10,7 +10,7 @@ use indexmap::IndexMap;
use locallibrary::api::{self, LibraryCache, PluginFieldsRequest};
use locallibrary::fields::roles;
use locallibrary::fields::{FieldValue, PluginsFields, Role};
-use locallibrary::plugin::{Analyzed, ApplyFields};
+use locallibrary::plugin::{AnalysisPart, Analyzed, ApplyAnalysis, ApplyFields};
use locallibrary::progress::Ctx;
use serde::Serialize;
@@ -32,12 +32,17 @@ pub struct AnalyzeArgs {
/// Only these tracks, by id (repeatable)
#[arg(long = "track", value_name = "ID")]
tracks: Vec,
- /// Then write what the analyzer worked out to the tracks (`key`, `bpm`, or both
- /// comma-separated), planned and asked about as any edit is: only where a track has none,
- /// unless --overwrite
- #[arg(long, value_name = "FIELDS", value_delimiter = ',', value_parser = ["key", "bpm"])]
+ /// Then write what the analyzer worked out to the tracks (`key` and `bpm`, or `grid`,
+ /// `phrases` and `waveform` into their analysis files; comma-separated), planned and asked
+ /// about as any edit is: only where a track has none, unless --overwrite
+ #[arg(
+ long,
+ value_name = "FIELDS",
+ value_delimiter = ',',
+ value_parser = ["key", "bpm", "grid", "phrases", "waveform"]
+ )]
apply: Vec,
- /// With --apply: also over a key or BPM a track has
+ /// With --apply: also over what a track has
#[arg(long, requires = "apply")]
overwrite: bool,
/// With --apply: leave out values the analyzer is less sure of than this (0 to 1)
@@ -67,25 +72,37 @@ struct Ran {
/// keeping what they work out for `track show`. Some tracks failing is a partial outcome (exit
/// status 5), every one failing an error. With `--apply`, only the analyzer for those fields runs
/// (the one chosen for them, when several could), and what it worked out is then planned onto the
-/// tracks.
+/// tracks: a key and BPM into the databases, or a beat grid, phrases and a waveform into their
+/// analysis files (`plugins.apply_analysis`), one or the other in a run.
pub fn analyze(args: AnalyzeArgs, store_path: &Option, out: Output, ctx: &Ctx<'_>) -> Result {
let plugins = plugins::host()?;
if let Some(p) = &args.plugin {
plugins.manifest(p)?;
}
- let apply: Vec = args.apply.iter().map(|r| r.parse().map_err(usage)).collect::>()?;
+ let (parts, roles): (Vec<&String>, Vec<&String>) =
+ args.apply.iter().partition(|f| AnalysisPart::ALL.iter().any(|p| p.name() == f.as_str()));
+ if !parts.is_empty() && !roles.is_empty() {
+ return Err(usage("write a key and BPM in a run of their own, apart from a grid, phrases and a waveform"));
+ }
+ if !parts.is_empty() && args.min_confidence.is_some() {
+ return Err(usage("--min-confidence is for a key and BPM"));
+ }
+ let parts: Vec =
+ AnalysisPart::ALL.into_iter().filter(|p| parts.iter().any(|f| f.as_str() == p.name())).collect();
+ let apply: Vec = roles.iter().map(|r| r.parse().map_err(usage)).collect::>()?;
let mut selected: Vec<(&str, &str)> = plugins
.manifests()
.filter(|m| args.plugin.as_ref().is_none_or(|p| *p == m.id))
.flat_map(|m| m.contributes.analyzers.iter().map(move |a| (m, a)))
.filter(|(_, a)| args.analyzer.as_deref().is_none_or(|n| n == a.name))
.filter(|(_, a)| apply.iter().all(|r| a.roles.contains_key(r)))
+ .filter(|(_, a)| parts.iter().all(|p| a.fields.values().any(|t| *t == p.kind())))
.map(|(m, a)| (m.id.as_str(), a.name.as_str()))
.collect();
- let fields: Vec<&str> = apply.iter().map(|r| r.name()).collect();
+ let fields: Vec<&str> = apply.iter().map(|r| r.name()).chain(parts.iter().map(|p| p.name())).collect();
if selected.is_empty() {
return Err(usage(match (&args.analyzer, &args.plugin) {
- _ if !apply.is_empty() => format!("no plugin's analyzer here works out {}", fields.join(" and ")),
+ _ if !fields.is_empty() => format!("no plugin's analyzer here works out {}", fields.join(" and ")),
(Some(a), _) => format!("no plugin has an analyzer {a:?} (`locallibrary plugin list` lists them)"),
(None, Some(p)) => format!("the {p} plugin has no analyzer"),
(None, None) => "no plugin locallibrary has analyses tracks".into(),
@@ -99,12 +116,12 @@ pub fn analyze(args: AnalyzeArgs, store_path: &Option, out: Output, ctx
chosen.as_ref().is_some_and(|f| f.analyzer.plugin == *p && f.analyzer.analyzer == *a)
};
selected.retain(named);
- if selected.is_empty() {
- return Err(usage(format!(
- "several analyzers work out {}: say which with --plugin and --analyzer",
- fields.join(" and ")
- )));
- }
+ }
+ if (!fields.is_empty() && selected.len() > 1) || selected.is_empty() {
+ return Err(usage(format!(
+ "several analyzers work out {}: say which with --plugin and --analyzer",
+ fields.join(" and ")
+ )));
}
let tracks = (!args.tracks.is_empty()).then_some(args.tracks.as_slice());
let mut analyzers = Vec::new();
@@ -118,7 +135,7 @@ pub fn analyze(args: AnalyzeArgs, store_path: &Option, out: Output, ctx
bail!("every track failed ({failed}): the warnings above say why");
}
let ran = Analyzing { drive, analyzers };
- if apply.is_empty() {
+ if fields.is_empty() {
let outcome = if failed > 0 { Outcome::Partial } else { Outcome::Applied };
return Ok(out.done_with(&ran, outcome, said));
}
@@ -127,16 +144,23 @@ pub fn analyze(args: AnalyzeArgs, store_path: &Option, out: Output, ctx
}
// Narrowed to one above.
let Ran { plugin, analyzer, .. } = &ran.analyzers[0];
- let req = ApplyFields {
- plugin: plugin.clone(),
- analyzer: analyzer.clone(),
- drive: ran.drive.clone(),
- fields: apply,
- tracks: tracks.map(<[u32]>::to_vec),
- overwrite: args.overwrite,
- min_confidence: args.min_confidence,
+ let (plugin, analyzer, drive, tracks) =
+ (plugin.clone(), analyzer.clone(), ran.drive.clone(), tracks.map(<[u32]>::to_vec));
+ let planned = if parts.is_empty() {
+ let req = ApplyFields {
+ plugin,
+ analyzer,
+ drive,
+ fields: apply,
+ tracks,
+ overwrite: args.overwrite,
+ min_confidence: args.min_confidence,
+ };
+ plugins.plan_apply_fields(&req, &store, ctx)?
+ } else {
+ let req = ApplyAnalysis { plugin, analyzer, drive, parts, tracks, overwrite: args.overwrite };
+ plugins.plan_apply_analysis(&req, &store, ctx)?
};
- let planned = plugins.plan_apply_fields(&req, &store, ctx)?;
if !out.json {
heading(&planned.summary[0]);
}
diff --git a/apps/locallibrary/src/plugins.rs b/apps/locallibrary/src/plugins.rs
index 7d1569d9..2aa2edac 100644
--- a/apps/locallibrary/src/plugins.rs
+++ b/apps/locallibrary/src/plugins.rs
@@ -372,6 +372,7 @@ fn means(c: &Capability) -> String {
Capability::TagsPlan => "plan My Tag changes, which you confirm",
Capability::CuesPlan => "plan cue changes, which you confirm",
Capability::ArtworkPlan => "plan cover art changes, which you confirm",
+ Capability::AnalysisPlan => "plan beat grid, phrase and waveform changes, which you confirm",
Capability::Net(host) => {
return match host.strip_prefix("*.") {
Some(domain) => format!("talk to any server under {domain}"),
diff --git a/apps/locallibrary/src/rpc/methods/plugins.rs b/apps/locallibrary/src/rpc/methods/plugins.rs
index 8f0a356e..30ef097e 100644
--- a/apps/locallibrary/src/rpc/methods/plugins.rs
+++ b/apps/locallibrary/src/rpc/methods/plugins.rs
@@ -2,6 +2,7 @@
//! (see loclib-plugin's `hooks`), registered from a module of its own. Their plans are kept apart
//! from locallibrary's own (see [`super::super::Plans::plugins`]).
+mod analysis;
mod analyzers;
mod commands;
mod exporters;
@@ -79,6 +80,7 @@ pub(super) fn register(r: &mut Registry) {
.keeps_cache();
// Each family's, in the order the contract lists them (a family's placed as loclib-plugin's
// `hooks` says).
+ analysis::register(r);
commands::register(r);
operations::register(r);
exporters::register(r);
diff --git a/apps/locallibrary/src/rpc/methods/plugins/analysis.rs b/apps/locallibrary/src/rpc/methods/plugins/analysis.rs
new file mode 100644
index 00000000..a4d69fb8
--- /dev/null
+++ b/apps/locallibrary/src/rpc/methods/plugins/analysis.rs
@@ -0,0 +1,24 @@
+//! `plugins.apply_analysis.*`: writing the beat grid, phrases and waveform an analyzer worked out
+//! (feature `analysis`) to the tracks' analysis files.
+
+use locallibrary::edit::EditPlan;
+use locallibrary::op::Planned;
+use locallibrary::plugin::ApplyAnalysis;
+
+use crate::plugins::host;
+use crate::rpc::RpcError;
+use crate::rpc::methods::{Call, PlanStore, Registry};
+
+pub(super) fn register(r: &mut Registry) {
+ // Overwriting what tracks have, when asked to.
+ r.plan("plugins.apply_analysis.plan", plan).long().destructive();
+ r.apply("plugins.apply_analysis.apply", PlanStore::None, |c, req: ApplyAnalysis| {
+ Ok(plan(c, req)?.apply(None, c.ctx)?)
+ })
+ .long()
+ .destructive();
+}
+
+fn plan(c: &Call<'_>, req: ApplyAnalysis) -> Result, RpcError> {
+ Ok(host()?.plan_apply_analysis(&req, &c.store()?, c.ctx)?)
+}
diff --git a/apps/locallibrary/src/tui/app.rs b/apps/locallibrary/src/tui/app.rs
index c5115b05..39687331 100644
--- a/apps/locallibrary/src/tui/app.rs
+++ b/apps/locallibrary/src/tui/app.rs
@@ -13,7 +13,7 @@ use std::time::{Duration, Instant};
use locallibrary::anlz::{self, PhraseKind, TrackAnalysis};
use locallibrary::api;
use locallibrary::error::panic_text;
-use locallibrary::fields::TrackValues;
+use locallibrary::fields::{OverlayStore, TrackValues};
use locallibrary::model::normalize;
use locallibrary::op::AnyPlanned;
use locallibrary::query::{FieldName, Filter, SortField, SortKey};
@@ -448,24 +448,33 @@ pub struct Details {
}
/// What starting a track reads in the background: its analysis files and artwork from the
-/// drive, and its matches and playlists from the library.
+/// drive (what they lack from what plugins' analyzers kept in the store, if they have it), and its
+/// matches and playlists from the library.
#[derive(Default)]
pub struct TrackInfo {
analysis: TrackAnalysis,
+ /// What of the analysis is a plugin's, not the drive's: a line to show.
+ from_plugins: Option,
art: Option,
matches: Option,
playlists: Vec,
}
-fn track_info(root: &Path, track: &Track, library: Option>) -> TrackInfo {
+fn track_info(root: &Path, track: &Track, library: Option>, store_root: &Path) -> TrackInfo {
let device = Device::open(root).ok();
- let analysis = device.as_ref().map(|d| anlz::load(d, track)).unwrap_or_default();
+ let mut analysis = device.as_ref().map(|d| anlz::load(d, track)).unwrap_or_default();
+ let analyzers = crate::plugins::host().map(|p| p.analyzers()).unwrap_or_default();
+ let audio = device.as_ref().and_then(|d| d.checked_path(&paths::key(&track.file_path)).ok());
+ let from_plugins = audio.filter(|_| !analyzers.is_empty()).and_then(|audio| {
+ let overlays = OverlayStore::in_store(&Store::open(store_root).ok()?);
+ overlays.first(&audio, &analyzers).fill(&mut analysis)
+ });
let art = (device.as_ref().and_then(|d| artwork::file_on_drive(d, track)))
.and_then(|f| std::fs::read(f).ok())
.and_then(|b| Art::decode(&b));
let playlists = library.as_deref().map(|lib| playlists_of(lib, track)).unwrap_or_default();
let matches = library.map(|lib| Matches::find(lib, root.to_path_buf(), track, 40));
- TrackInfo { analysis, art, matches, playlists }
+ TrackInfo { analysis, from_plugins, art, matches, playlists }
}
pub struct NowPlaying {
@@ -475,6 +484,8 @@ pub struct NowPlaying {
pub drive: String,
pub root: PathBuf,
pub analysis: TrackAnalysis,
+ /// What of the analysis is a plugin's, not the drive's: a line to show.
+ pub from_plugins: Option,
pub art: Option,
pub duration: Duration,
pub key: Option,
@@ -1824,6 +1835,7 @@ impl App {
let info = *info;
now.phrases = phrase_spans(&info.analysis, now.duration);
now.analysis = info.analysis;
+ now.from_plugins = info.from_plugins;
now.art = info.art;
now.matches = info.matches;
now.playlists = info.playlists;
@@ -2518,7 +2530,7 @@ impl App {
fn open(&mut self, track: Track, starts: bool) -> NowPlaying {
let id = self.next_id();
let root = self.queue.root.clone();
- let (t, r, auto_cue) = (track.clone(), root.clone(), self.deck.auto_cue);
+ let (t, r, auto_cue, store_root) = (track.clone(), root.clone(), self.deck.auto_cue, self.store_root.clone());
// Matches and playlists need the drive's library, which is open unless the player
// moved to another drive since the queue was made.
let library = self.loaded.as_ref().filter(|l| l.root() == self.queue.root).map(|l| l.library.clone());
@@ -2531,7 +2543,7 @@ impl App {
Ok(file)
});
if o.send(Msg::Opened(id, file.map_err(|e| format!("{e:#}")))) {
- o.send(Msg::TrackInfo(id, Box::new(track_info(&r, &t, library))));
+ o.send(Msg::TrackInfo(id, Box::new(track_info(&r, &t, library, &store_root))));
}
};
self.opener.open(&self.tx, id, starts, Box::new(job));
@@ -2543,6 +2555,7 @@ impl App {
drive: self.queue.drive.clone(),
root,
analysis: TrackAnalysis::default(),
+ from_plugins: None,
art: None,
phrases: Vec::new(),
matches: None,
@@ -2865,6 +2878,7 @@ impl App {
duration: Duration::from_secs(track.duration_secs as u64),
track,
analysis: TrackAnalysis::default(),
+ from_plugins: None,
art: None,
phrases: Vec::new(),
matches: None,
diff --git a/apps/locallibrary/src/tui/app/props.rs b/apps/locallibrary/src/tui/app/props.rs
index 9cbe6290..360f62f9 100644
--- a/apps/locallibrary/src/tui/app/props.rs
+++ b/apps/locallibrary/src/tui/app/props.rs
@@ -2245,7 +2245,8 @@ fn any_field_value(tc: &TestCase, kind: usize) -> Option std::string::String {
use locallibrary::fields::FieldValue::*;
match v {
- Null => "-".to_string(),
+ // A structured value is never a column; drawn, it's as if it were null.
+ Null | Structured(_) => "-".to_string(),
Integer(n) => n.to_string(),
Number(n) => format!("{n:.2}").trim_end_matches('0').trim_end_matches('.').to_string(),
Boolean(b) => if *b { "yes" } else { "no" }.to_string(),
diff --git a/apps/locallibrary/src/tui/app/testing.rs b/apps/locallibrary/src/tui/app/testing.rs
index 15265d45..9c42a72a 100644
--- a/apps/locallibrary/src/tui/app/testing.rs
+++ b/apps/locallibrary/src/tui/app/testing.rs
@@ -218,6 +218,7 @@ pub fn playing(analysis: TrackAnalysis) -> (App, TempDir) {
drive: "TEST".into(),
root,
analysis,
+ from_plugins: None,
art: None,
duration: Duration::from_secs(300),
phrases: Vec::new(),
diff --git a/apps/locallibrary/src/tui/app/tests.rs b/apps/locallibrary/src/tui/app/tests.rs
index de175991..f03743f5 100644
--- a/apps/locallibrary/src/tui/app/tests.rs
+++ b/apps/locallibrary/src/tui/app/tests.rs
@@ -1280,6 +1280,7 @@ fn tracks_open_in_the_background() {
drive: "TEST".into(),
root,
analysis: TrackAnalysis::default(),
+ from_plugins: None,
art: None,
duration: Duration::from_secs(300),
phrases: Vec::new(),
@@ -3111,3 +3112,49 @@ fn the_deck_plays_any_analysis(tc: hegel::TestCase) {
}
}
}
+
+/// What a track's drive lacks of its analysis comes from what a plugin's analyzer kept, read with
+/// the rest in the background, and the deck says so under the track.
+#[test]
+fn a_plugins_analysis_fills_in_what_the_drive_lacks() {
+ let (dir, root) = demo_drive();
+ echo_plugin(dir.path());
+ let heard = "\n[[contributes.analyzers]]\nname = \"heard\"\ntitle = \"Heard\"\nversion = \"1\"\n\
+ fields = { grid = \"beats\", wave = \"waveform\" }\n";
+ let manifest = std::fs::read_to_string(dir.path().join("echo.toml")).unwrap();
+ std::fs::write(dir.path().join("echo.toml"), manifest + heard).unwrap();
+ let device = Device::open_export(&root).unwrap();
+ let t = device.library().unwrap().tracks[0].clone();
+ // The drive has no analysis for it: rekordbox's files are gone.
+ let dat = device.path(&paths::key(&t.analyze_path));
+ for ext in ["DAT", "EXT", "2EX"] {
+ std::fs::remove_file(dat.with_extension(ext)).unwrap();
+ }
+ let grid: Vec =
+ (0..8).map(|i| Beat { beat_number: i % 4 + 1, bpm: 120.0, time_ms: 500 * u32::from(i) }).collect();
+ let heard = serde_json::json!({
+ "grid": grid,
+ "wave": { "blob": "w.bands", "columns_per_sec": 150.0 },
+ "blobs": { "w.bands": [10, 20, 30, 40, 50, 60] },
+ });
+ std::fs::write(device.path(&paths::key(&t.file_path)), heard.to_string()).unwrap();
+ let store_root = dir.path().join("store");
+ let store = Store::open_or_init(&store_root).unwrap();
+ let plugins = crate::plugins::host().unwrap();
+ plugins.analyze("echo", "heard", &root.display().to_string(), None, &store, &Ctx::silent()).unwrap();
+
+ let info = track_info(&root, &t, None, &store_root);
+ let said = "grid and waveform from echo.heard, not on the drive";
+ assert_eq!(info.from_plugins.as_deref(), Some(said));
+ assert_eq!(info.analysis.beats, grid);
+ let detail = info.analysis.detail.as_ref().unwrap();
+ assert_eq!((detail.style, detail.columns.len()), (anlz::WaveformStyle::ThreeBand, 2));
+ // Another track, which rekordbox analysed, is all the drive's.
+ let other = device.library().unwrap().tracks[1].clone();
+ assert_eq!(track_info(&root, &other, None, &store_root).from_plugins, None);
+
+ let (mut app, _played) = playing(info.analysis);
+ app.now.as_mut().unwrap().from_plugins = info.from_plugins;
+ let s = crate::tui::ui::screen(&mut app, 160, 45);
+ assert!(s.contains(said), "{s}");
+}
diff --git a/apps/locallibrary/src/tui/ui.rs b/apps/locallibrary/src/tui/ui.rs
index 173bc904..6bfd1e71 100644
--- a/apps/locallibrary/src/tui/ui.rs
+++ b/apps/locallibrary/src/tui/ui.rs
@@ -685,7 +685,7 @@ impl Col {
/// nothing there at all).
fn field_cell(v: &FieldValue) -> Line<'static> {
match v {
- FieldValue::Null => Line::styled("-", faint()).right_aligned(),
+ FieldValue::Null | FieldValue::Structured(_) => Line::styled("-", faint()).right_aligned(),
FieldValue::Number(n) => {
let s = format!("{n:.2}");
Line::styled(s.trim_end_matches('0').trim_end_matches('.').to_string(), fg(theme::AMBER)).right_aligned()
@@ -1411,6 +1411,10 @@ fn draw_info(f: &mut Frame, app: &App, area: Rect, big: bool) {
}
fmt.push(now.drive.clone());
lines.push(Line::styled(fmt.join(" · "), faint()));
+ // What a plugin's analyzer worked out, drawn for what the drive lacks.
+ if let Some(from) = &now.from_plugins {
+ lines.push(Line::styled(from.clone(), dim()));
+ }
let cues = &now.analysis.cues;
if !cues.is_empty() {
diff --git a/apps/locallibrary/src/tui/ui/tests.rs b/apps/locallibrary/src/tui/ui/tests.rs
index 4468249c..c903ab60 100644
--- a/apps/locallibrary/src/tui/ui/tests.rs
+++ b/apps/locallibrary/src/tui/ui/tests.rs
@@ -187,6 +187,7 @@ fn draws_every_pane_and_dialog_at_any_size() {
drive: "TEST".into(),
root: l.root().to_path_buf(),
analysis: Default::default(),
+ from_plugins: None,
art: None,
duration: std::time::Duration::from_secs(300),
phrases: Vec::new(),
diff --git a/apps/locallibrary/tests/it/commands/analysis.rs b/apps/locallibrary/tests/it/commands/analysis.rs
index cf72a8f6..f2f1a21b 100644
--- a/apps/locallibrary/tests/it/commands/analysis.rs
+++ b/apps/locallibrary/tests/it/commands/analysis.rs
@@ -131,7 +131,7 @@ fn analyze_writes_keys_and_bpms_to_the_tracks() {
let err = env.fails(2, &["analyze", path(&root), "--overwrite"]);
assert!(err.contains("--apply"), "{err}");
let err = env.fails(2, &["analyze", path(&root), "--apply", "loudness"]);
- assert!(err.contains("[possible values: key, bpm]"), "{err}");
+ assert!(err.contains("[possible values: key, bpm, grid, phrases, waveform]"), "{err}");
let err = env.fails(2, &["analyze", path(&root), "--plugin", "sound", "--analyzer", "nope", "--apply", "key"]);
assert!(err.contains("no plugin's analyzer here works out key"), "{err}");
}
@@ -286,3 +286,48 @@ fn analyze_applies_from_the_analyzer_chosen_for_the_role() {
let out = env.ok(&["analyze", path(&root), "--apply", "key", "--dry-run"]);
assert!(out.contains("heard (echo): 2 tracks analyzed") && !out.contains("(sound)"), "{out}");
}
+
+/// `--apply grid,phrases,waveform` writes what an analyzer worked out besides its columns into the
+/// tracks' analysis files, planned as any edit is, and the sidecar plans it too; a key or BPM goes
+/// in a run of its own.
+#[cfg(debug_assertions)]
+#[test]
+fn analyze_writes_a_grid_into_the_analysis_files() {
+ let mut env = Env::new();
+ let heard = "\n[[contributes.analyzers]]\nname = \"heard\"\ntitle = \"Heard\"\nversion = \"1\"\n\
+ fields = { grid = \"beats\", tone = \"string\" }\nroles = { key = \"tone\" }\n";
+ env.plugins(&[&(crate::support::echo_plugin() + heard)]);
+ let root = env.demo("A");
+ let grid: Vec =
+ (0..16).map(|i| json!({ "beat_number": i % 4 + 1, "bpm": 125.0, "time_ms": 30 + i * 480 })).collect();
+ // What the test plugin's `heard` answers: what's in the audio file.
+ for t in Device::open(&root).unwrap().library().unwrap().tracks {
+ std::fs::write(paths::to_fs(&root, &paths::key(&t.file_path)), json!({ "grid": grid }).to_string()).unwrap();
+ }
+ let err = env.fails(2, &["analyze", path(&root), "--apply", "key,grid"]);
+ assert!(err.contains("write a key and BPM in a run of their own"), "{err}");
+ let err = env.fails(2, &["analyze", path(&root), "--apply", "grid", "--min-confidence", "0.5"]);
+ assert!(err.contains("--min-confidence is for a key and BPM"), "{err}");
+ let out = env.ok(&["analyze", path(&root), "--apply", "grid", "--dry-run"]);
+ assert!(out.contains("heard (echo): 2 tracks analyzed"), "{out}");
+ assert!(out.contains("2 of what's asked the tracks have already"), "{out}");
+ let out = env.ok(&["--yes", "analyze", path(&root), "--apply", "grid", "--overwrite"]);
+ assert!(out.contains("write the grid from Heard to 2 track(s)"), "{out}");
+ let d = Device::open(&root).unwrap();
+ for t in d.library().unwrap().tracks {
+ let beats = locallibrary::anlz::load(&d, &t).beats;
+ assert_eq!((beats.len(), beats[1].time_ms, t.bpm), (16, 510, 125.0));
+ }
+
+ let mut s = Rpc::start(env.command());
+ let apply =
+ json!({ "plugin": "echo", "analyzer": "heard", "drive": path(&root), "parts": ["grid"], "overwrite": true });
+ let planned = s.call(1, "plugins.apply_analysis.plan", apply);
+ assert_eq!(planned["result"]["noop"], true, "as it is now: {planned}");
+ let nothing = s.call(
+ 2,
+ "plugins.apply_analysis.plan",
+ json!({ "plugin": "echo", "analyzer": "heard", "drive": path(&root), "parts": [] }),
+ );
+ assert_eq!(nothing["error"]["data"]["kind"], "invalid", "{nothing}");
+}
diff --git a/crates/locallibrary/src/analysis_edit.rs b/crates/locallibrary/src/analysis_edit.rs
new file mode 100644
index 00000000..fbc35917
--- /dev/null
+++ b/crates/locallibrary/src/analysis_edit.rs
@@ -0,0 +1,359 @@
+//! Writing a track's beat grid, phrases and waveform into its analysis files, with
+//! [`crate::anlz`]'s writers: an edit ([`crate::edit::Edit::SetAnalysis`]) made as a cue edit is,
+//! backed up first and journaled, so `undo` takes it back and `edits replay` makes it again after
+//! rekordbox exports to the drive. The journal names a waveform staged on the drive by its hash
+//! (`.locallibrary/waveforms/.bands`).
+//!
+//! A track takes one in the analysis files it has: a beat grid in its `.DAT` (the `.EXT`'s
+//! extended grid, which can't be written, goes, see [`anlz::without_extended_grid`]), phrases in
+//! its `.EXT`, and a waveform in each file's waveform tags (the blue overview in the `.DAT`, the
+//! blue and colour detail in the `.EXT`, the 3-band ones in a `.2EX`). A track without analysis
+//! files is refused, and so is a waveform for one whose files hold a tag that can't be worked
+//! out from it (rekordbox's colour overview, `PWV4`, and the `.2EX`'s `PWVC`): its waveforms stay
+//! rekordbox's. Cues stay where they are when the grid moves (a warning says how many sat on its
+//! beats), and the plan that sets a grid sets the track's BPM in the databases too
+//! ([`crate::edit::plan`]).
+//!
+//! What's written is only what the readers read back as given, in layouts checked against
+//! rekordbox's own files; how players take a grid, phrases or a waveform written here is for real
+//! players to show.
+
+use rustc_hash::FxHashMap;
+
+use base64::prelude::*;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+use crate::anlz::{self, Bands, Beat, SongStructure};
+use crate::commit::{self, Changes, NewFile, Options};
+use crate::cue_edit::{clock, read_optional, sibling_key};
+use crate::device::{Device, LineageOp};
+use crate::edit::Replayed;
+use crate::fsutil;
+use crate::hash::Hash;
+use crate::model::{Library, Track};
+use crate::paths::{self, FileClass};
+use crate::progress::Ctx;
+use crate::staged::WAVEFORMS;
+use crate::{Error, Result};
+
+/// What an analysis edit writes; what it leaves out stays as it is.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
+pub struct NewAnalysis {
+ /// The beat grid: each beat's number in its bar (1-4), the tempo there and its time.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub beats: Option>,
+ /// The phrases, named as the track's mood names them.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub phrases: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub waveform: Option,
+}
+
+/// A waveform an analysis edit writes: three bands, each column's lows, mids and highs (0-255),
+/// 150 columns a second ([`Bands`]), from which each of the track's waveform tags is worked out.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum WaveSource {
+ /// The bands packed (three bytes a column), in base64.
+ Base64(String),
+ /// Staged on the drive (`.locallibrary/waveforms/.bands`), by its hash: how the journal
+ /// records the others.
+ Staged(Hash),
+ /// A file a plugin's answer names in its request's scratch folder, packed: how plugins give
+ /// waveforms. The plugin host reads its bytes once it has checked it.
+ #[schemars(skip)]
+ Blob(String),
+}
+
+impl NewAnalysis {
+ pub fn is_empty(&self) -> bool {
+ self.beats.is_none() && self.phrases.is_none() && self.waveform.is_none()
+ }
+
+ /// `set the beat grid and phrases of /Contents/a.mp3`.
+ pub fn describe(&self, track: &str) -> String {
+ let parts: Vec<&str> = [
+ self.beats.as_ref().map(|_| "the beat grid"),
+ self.phrases.as_ref().map(|_| "phrases"),
+ self.waveform.as_ref().map(|_| "the waveform"),
+ ]
+ .into_iter()
+ .flatten()
+ .collect();
+ format!("set {} of {track}", crate::op::and_list(&parts))
+ }
+
+ /// The tempo a track with this grid has in the databases: the one most of its beats have
+ /// (of those alike, the first's), to hundredths.
+ pub fn tempo(&self) -> Option {
+ let beats = self.beats.as_ref()?;
+ let mut counts: Vec<(i64, usize)> = Vec::new();
+ for b in beats {
+ let hundredths = (b.bpm * 100.0).round() as i64;
+ match counts.iter_mut().find(|(t, _)| *t == hundredths) {
+ Some((_, n)) => *n += 1,
+ None => counts.push((hundredths, 1)),
+ }
+ }
+ // `max_by_key` keeps the last of equals: reversed, that's the first.
+ counts.into_iter().rev().max_by_key(|(_, n)| *n).map(|(t, _)| t as f64 / 100.0)
+ }
+}
+
+/// The waveform an analysis edit writes, as the drive keeps it for the journal, named by its hash,
+/// with its bytes unless it was given staged (the plan holds them, as an artwork edit's image).
+pub(crate) fn stage(device: &Device, wave: &WaveSource) -> Result<(Hash, Option>)> {
+ let bytes = match wave {
+ WaveSource::Staged(hash) if WAVEFORMS.path(device, hash).is_file() => return Ok((*hash, None)),
+ WaveSource::Staged(hash) => {
+ return Err(Error::NotFound(format!("the staged waveform {} on {}", hash.short(), device.name())));
+ }
+ WaveSource::Blob(_) => return Err(Error::invalid("only a plugin's plan gives a waveform as a blob")),
+ WaveSource::Base64(text) => {
+ BASE64_STANDARD.decode(text).map_err(|e| Error::invalid(format!("the waveform isn't base64: {e}")))?
+ }
+ };
+ if bytes.is_empty() || Bands::unpack(&bytes).is_none() {
+ return Err(Error::invalid("a waveform is three bytes a column (lows, mids, highs), at least one column"));
+ }
+ Ok((Hash::of(&bytes), Some(bytes)))
+}
+
+/// A track's analysis files, as keys, with what each holds now: its `.DAT`, `.EXT` and `.2EX`.
+struct Files {
+ keys: [String; 3],
+ bytes: [Option>; 3],
+}
+
+/// The keys of the analysis files of the track at `analyze_path` (a device path), `.DAT` first,
+/// once it's an analysis file's path on the drive.
+fn keys(t: &Track) -> Result<[String; 3]> {
+ let no_analysis = || {
+ Error::invalid(format!(
+ "{} has no analysis files (rekordbox hasn't analysed it), so it can't hold a beat grid, phrases or waveforms",
+ t.display_name()
+ ))
+ };
+ if t.analyze_path.is_empty() {
+ return Err(no_analysis());
+ }
+ let dat = paths::key(&t.analyze_path);
+ let refused = || Error::invalid(format!("refusing analysis path {:?} of {}", t.analyze_path, t.display_name()));
+ if !paths::is_safe_key(&dat) || paths::classify(&dat) != Some(FileClass::Analysis) {
+ return Err(refused());
+ }
+ let [ext, two] = ["EXT", "2EX"].map(|e| sibling_key(&dat, e));
+ Ok([dat, ext.ok_or_else(refused)?, two.ok_or_else(refused)?])
+}
+
+fn read(device: &Device, t: &Track) -> Result {
+ let keys = keys(t)?;
+ let bytes = [read_optional(device, &keys[0])?, read_optional(device, &keys[1])?, read_optional(device, &keys[2])?];
+ if bytes[0].is_none() {
+ return Err(Error::invalid(format!("{} has no .DAT analysis file", t.display_name())));
+ }
+ Ok(Files { keys, bytes })
+}
+
+/// The analysis files an analysis edit of the track of the audio file `file` (a device path) may
+/// rewrite, as keys: what the backup made before it copies.
+pub(crate) fn analysis_files(device: &Device, library: &Library, file: &str) -> Vec {
+ let keys = track(library, file).and_then(keys).into_iter().flatten();
+ keys.filter(|k| device.checked_path(k).is_ok_and(|p| p.is_file())).collect()
+}
+
+/// The waveform tags each analysis file takes, `.DAT`, `.EXT` and `.2EX`.
+const WAVEFORM_TAGS: [&[&[u8; 4]]; 3] = [&[b"PWAV", b"PWV2"], &[b"PWV3", b"PWV5"], &[b"PWV6", b"PWV7"]];
+
+/// The analysis files an edit rewrites, by key, with their new bytes.
+type Rewritten = Vec<(String, Vec)>;
+
+/// What the edit rewrites in a track's analysis files (only those that change), and what to warn
+/// of. `bands` is the waveform's.
+fn rewrite(t: &Track, files: &Files, a: &NewAnalysis, bands: Option<&Bands>) -> Result<(Rewritten, Vec)> {
+ let name = t.display_name();
+ let mut new = files.bytes.clone();
+ let mut warnings = Vec::new();
+ let past_end = |ms: u32| t.duration_secs > 0 && u64::from(ms) >= (u64::from(t.duration_secs) + 1) * 1000;
+ let mut grid = anlz::beat_grid(new[0].as_ref().expect("read")).unwrap_or_default();
+ if let Some(beats) = &a.beats {
+ let beats = anlz::checked_grid(beats)?;
+ if let Some((i, b)) = beats.iter().enumerate().find(|(_, b)| past_end(b.time_ms)) {
+ return Err(Error::invalid(format!("beat {} at {} is past the end of {name}", i + 1, clock(b.time_ms))));
+ }
+ if beats != grid {
+ let dat = new[0].as_ref().expect("read");
+ let cues = anlz::read_cue_points(dat, new[1].as_deref())?;
+ let on = |grid: &[Beat], ms: u32| grid.iter().any(|b| b.time_ms.abs_diff(ms) <= 1);
+ let moved = cues.iter().filter(|c| on(&grid, c.start_ms) && !on(&beats, c.start_ms)).count();
+ if moved > 0 {
+ warnings.push(format!(
+ "{moved} cue(s) of {name} sat on beats of its old grid: they stay where they are, off the new one"
+ ));
+ }
+ new[0] = Some(anlz::write_beat_grid(dat, &beats)?);
+ if let Some(ext) = &new[1] {
+ let without = anlz::without_extended_grid(ext)?;
+ if without != *ext {
+ warnings.push(format!(
+ "{name}'s .EXT loses its extended beat grid (PQT2), which locallibrary can't write"
+ ));
+ new[1] = Some(without);
+ }
+ }
+ grid = beats;
+ }
+ }
+ if let Some(p) = &a.phrases {
+ let Some(ext) = &new[1] else {
+ return Err(Error::invalid(format!("{name} has no .EXT analysis file to hold phrases")));
+ };
+ new[1] = Some(anlz::write_song_structure(ext, Some(p))?);
+ }
+ let phrases = new[1].as_deref().and_then(|e| anlz::song_structure(e).ok().flatten());
+ if let Some(last) =
+ phrases.as_ref().and_then(|s| s.phrases.last()).filter(|p| usize::from(p.end_beat) > grid.len() + 1)
+ {
+ warnings.push(format!(
+ "{name}'s phrases run to beat {}, past its beat grid's {} beats",
+ last.end_beat,
+ grid.len()
+ ));
+ }
+ if let Some(bands) = bands {
+ for (i, tag) in [(1, b"PWV4"), (2, b"PWVC")] {
+ let Some(bytes) = new[i].as_deref() else { continue };
+ if anlz::has_section(bytes, tag)? {
+ return Err(Error::invalid(format!(
+ "{name}'s analysis files hold a waveform tag ({}) that can't be worked out from another waveform: its waveforms stay rekordbox's",
+ String::from_utf8_lossy(tag)
+ )));
+ }
+ }
+ let secs = bands.0.len() as f64 / anlz::DETAIL_RATE;
+ if t.duration_secs > 0 && (secs - f64::from(t.duration_secs)).abs() > 2.0 {
+ warnings.push(format!("{name}'s waveform is {secs:.0} s long, the track {} s", t.duration_secs));
+ }
+ if new[1].is_none() {
+ warnings.push(format!("{name} has no .EXT analysis file, so only its overview waveform is written"));
+ }
+ for (file, tags) in new.iter_mut().zip(WAVEFORM_TAGS) {
+ let Some(bytes) = file else { continue };
+ for tag in tags {
+ let columns = anlz::waveform_from_bands(tag, bands)?.expect("every tag here is worked out");
+ *bytes = anlz::write_waveform(bytes, tag, &columns)?;
+ }
+ }
+ }
+ let changed = files
+ .keys
+ .iter()
+ .zip(&files.bytes)
+ .zip(new)
+ .filter_map(|((key, old), new)| new.filter(|n| Some(n) != old.as_ref()).map(|n| (key.clone(), n)))
+ .collect();
+ Ok((changed, warnings))
+}
+
+/// The track of the audio file `file` (a device path) in `library`.
+fn track<'a>(library: &'a Library, file: &str) -> Result<&'a Track> {
+ let key = paths::file_key(file);
+ library
+ .tracks
+ .iter()
+ .find(|t| paths::file_key(&t.file_path) == key)
+ .ok_or_else(|| Error::NotFound(format!("no track at {file}")))
+}
+
+/// The bands of the waveform an edit gives, staged as `hash` (from `staged`, else the drive's
+/// copy).
+fn staged_bands(device: &Device, hash: &Hash, staged: &FxHashMap>) -> Result {
+ let gone = || Error::NotFound(format!("the staged waveform {} on {}", hash.short(), device.name()));
+ let bytes = match staged.get(hash) {
+ Some(b) => b.clone(),
+ None => std::fs::read(WAVEFORMS.path(device, hash)).map_err(|_| gone())?,
+ };
+ Bands::unpack(&bytes).filter(|b| !b.0.is_empty()).ok_or_else(gone)
+}
+
+/// What making the edit on the track of `file` would warn of, planning it, its waveform staged
+/// (from `staged`, else the drive's copy): an error when it can't be made.
+pub(crate) fn check(
+ device: &Device,
+ library: &Library,
+ file: &str,
+ a: &NewAnalysis,
+ staged: &FxHashMap>,
+) -> Result> {
+ let bands = match &a.waveform {
+ Some(WaveSource::Staged(hash)) => Some(staged_bands(device, hash, staged)?),
+ Some(_) => return Err(Error::invalid("an analysis edit's waveform is staged as it's planned")),
+ None => None,
+ };
+ Ok(fits(device, track(library, file)?, a, bands.as_ref())?.1)
+}
+
+/// Whether writing `a` (its waveform `bands`) into `t`'s analysis files would change them, and
+/// what it would warn of: an error when it can't be written.
+pub(crate) fn fits(device: &Device, t: &Track, a: &NewAnalysis, bands: Option<&Bands>) -> Result<(bool, Vec)> {
+ let (changed, warnings) = rewrite(t, &read(device, t)?, a, bands)?;
+ Ok((!changed.is_empty(), warnings))
+}
+
+/// Make an analysis edit of a plan's on the track of `file` (a device path), its waveform staged
+/// (from `staged`, else the drive's copy), under the backup and lock the plan took (see
+/// [`crate::edit::apply`]). The waveform is staged first, for the journal entry to name.
+/// Replaying (not `strict`), one that no longer applies is skipped with a warning; an error is a
+/// failure while writing.
+pub(crate) fn replay(
+ device: &mut Device,
+ file: &str,
+ a: &NewAnalysis,
+ staged: &FxHashMap>,
+ strict: bool,
+ ctx: &Ctx<'_>,
+) -> Result {
+ let out = Replayed::default();
+ let described = a.describe(file);
+ let library = device.library()?;
+ let hash = match &a.waveform {
+ Some(WaveSource::Staged(hash)) => Some(*hash),
+ Some(_) => return Err(Error::invalid("an analysis edit's waveform is staged as it's planned")),
+ None => None,
+ };
+ let made = track(&library, file).and_then(|t| {
+ let bands = hash.map(|h| staged_bands(device, &h, staged)).transpose()?;
+ rewrite(t, &read(device, t)?, a, bands.as_ref())
+ });
+ let (changed, warnings) = match made {
+ Ok(made) => made,
+ Err(e) => return out.skipped(strict, &described, e),
+ };
+ let mut out = Replayed { warnings, ..out };
+ if changed.is_empty() {
+ return Ok(out);
+ }
+ // Not on the drive yet, it was read from `staged`.
+ let fresh = hash.map(|h| (WAVEFORMS.path(device, &h), h)).filter(|(p, _)| !p.is_file());
+ if let Some((path, hash)) = &fresh {
+ fsutil::write_atomic(path, &staged[hash])?;
+ }
+ let changes =
+ Changes { files: changed.into_iter().map(|(k, b)| NewFile::bytes(k, b)).collect(), ..Default::default() };
+ let swaps = device.swaps();
+ let done = commit::commit(device, changes, Options::new(LineageOp::Edit, "edit analysis"), ctx);
+ // Failing before it changed the drive, it leaves no staged waveform either (best effort: the
+ // error that got here is what's reported).
+ if done.is_err()
+ && device.swaps() == swaps
+ && let Some((path, _)) = &fresh
+ {
+ let _ = fsutil::remove_if_exists(path);
+ let _ = path.parent().map(std::fs::remove_dir);
+ }
+ let done = done?;
+ out.databases = done.written;
+ out.fingerprint = done.fingerprint;
+ Ok(out)
+}
diff --git a/crates/locallibrary/src/anlz.rs b/crates/locallibrary/src/anlz.rs
index 916b2c9d..2d19f33a 100644
--- a/crates/locallibrary/src/anlz.rs
+++ b/crates/locallibrary/src/anlz.rs
@@ -84,6 +84,11 @@ fn find<'a>(secs: &'a [SectionRef], tag: &[u8; 4]) -> Option<&'a SectionRef> {
secs.iter().find(|s| &s.tag == tag)
}
+/// Whether an analysis file has a section tagged `tag`.
+pub fn has_section(bytes: &[u8], tag: &[u8; 4]) -> Result {
+ Ok(find(§ions(bytes)?, tag).is_some())
+}
+
fn utf16be(bytes: &[u8]) -> String {
let units: Vec =
bytes.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).take_while(|&u| u != 0).collect();
diff --git a/crates/locallibrary/src/anlz/grid.rs b/crates/locallibrary/src/anlz/grid.rs
index 99b7fdf8..da9e6796 100644
--- a/crates/locallibrary/src/anlz/grid.rs
+++ b/crates/locallibrary/src/anlz/grid.rs
@@ -9,9 +9,9 @@
//! body (two bytes a beat: [pyrekordbox](https://github.com/dylanljones/pyrekordbox/blob/master/pyrekordbox/anlz/structs.py)
//! calls the first `beat` and leaves the second unexplained, and in rekordbox 6 exports the first
//! doesn't count beats in the bar) isn't understood, so it's never written: a new grid takes it out
-//! of the `.EXT`
-//! ([`without_extended_grid`]), leaving the file as rekordbox 5 exports were, without one. Moving
-//! a whole grid by some milliseconds keeps it ([`super::shift_times`]).
+//! of the `.EXT` ([`without_extended_grid`]) rather than leave one that contradicts it (how players
+//! take an `.EXT` without one is for players to show). Moving a whole grid by some milliseconds
+//! keeps it ([`super::shift_times`]).
use super::tags::{only, put, section};
use super::*;
diff --git a/crates/locallibrary/src/artwork.rs b/crates/locallibrary/src/artwork.rs
index e0a1d518..39793281 100644
--- a/crates/locallibrary/src/artwork.rs
+++ b/crates/locallibrary/src/artwork.rs
@@ -41,6 +41,7 @@ use crate::paths;
use crate::pdb;
use crate::progress::{Ctx, Event, Phase};
use crate::schema::{Failure, Skip};
+use crate::staged::IMAGES;
use crate::store::Store;
use crate::{Error, Result};
@@ -358,20 +359,13 @@ pub enum ImageSource {
Blob(String),
}
-/// Where the drive keeps the images artwork edits staged, below its state folder.
-const STAGED: &str = "artwork";
-
-fn staged_path(device: &Device, hash: &Hash) -> PathBuf {
- device.state_dir().join(STAGED).join(format!("{hash}.jpg"))
-}
-
/// The image an artwork edit sets, as the drive keeps it for the journal: a JPEG of the larger
/// size players show, named by its hash, with its bytes unless it was given staged (the plan holds
/// them, so pruning the drive's copy before it's applied can't lose them). The players' two sizes
/// are made from those bytes, so a replay sets exactly the same artwork.
pub(crate) fn stage(device: &Device, image: &ImageSource) -> Result<(Hash, Option>)> {
let art = match image {
- ImageSource::Staged(hash) if staged_path(device, hash).is_file() => return Ok((*hash, None)),
+ ImageSource::Staged(hash) if IMAGES.path(device, hash).is_file() => return Ok((*hash, None)),
ImageSource::Staged(hash) => {
return Err(Error::NotFound(format!("the staged image {} on {}", hash.short(), device.name())));
}
@@ -385,31 +379,6 @@ pub(crate) fn stage(device: &Device, image: &ImageSource) -> Result<(Hash, Optio
Ok((Hash::of(&bytes), Some(bytes)))
}
-/// Keep only the staged images in `keep` (the ones the journal names, see [`stage`]).
-pub(crate) fn prune_staged(device: &Device, keep: &FxHashSet) -> Result<()> {
- let dir = device.state_dir().join(STAGED);
- let Ok(entries) = std::fs::read_dir(&dir) else { return Ok(()) };
- for e in entries.flatten() {
- let name = e.file_name().to_string_lossy().into_owned();
- let named = name.strip_suffix(".jpg").and_then(|h| h.parse::().ok());
- if !named.is_some_and(|h| keep.contains(&h)) {
- fsutil::remove_if_exists(&e.path())?;
- }
- }
- Ok(())
-}
-
-/// Give `target` the staged images in `images` that `source` has and it lacks.
-pub(crate) fn copy_staged(source: &Device, target: &Device, images: &FxHashSet) -> Result<()> {
- for hash in images {
- let (from, to) = (staged_path(source, hash), staged_path(target, hash));
- if from.is_file() && !to.is_file() {
- fsutil::copy_file(&from, &to, None, Some(*hash), true, &Ctx::silent())?;
- }
- }
- Ok(())
-}
-
/// The images, in each database, of the tracks of these audio files (device paths): what setting
/// their artwork may replace in place, for the backup made first.
pub(crate) fn image_files(device: &Device, files: &[String]) -> Result> {
@@ -456,7 +425,7 @@ pub(crate) fn replay(
if ids.is_empty() {
return out.skipped(strict, &described, Error::NotFound("the tracks".into()));
}
- let path = staged_path(device, hash);
+ let path = IMAGES.path(device, hash);
let medium = match images.get(hash).cloned().map_or_else(|| std::fs::read(&path), Ok) {
Ok(bytes) => bytes,
Err(_) => {
diff --git a/crates/locallibrary/src/artwork/tests.rs b/crates/locallibrary/src/artwork/tests.rs
index 32538f8d..204eaf4f 100644
--- a/crates/locallibrary/src/artwork/tests.rs
+++ b/crates/locallibrary/src/artwork/tests.rs
@@ -79,7 +79,7 @@ fn images_are_staged_by_the_hash_of_their_jpeg(tc: TestCase) {
let kept = tc.draw(gs::booleans());
if kept {
- let staged = staged_path(&device, &hash);
+ let staged = crate::staged::IMAGES.path(&device, &hash);
std::fs::create_dir_all(staged.parent().unwrap()).unwrap();
std::fs::write(&staged, &jpeg).unwrap();
}
diff --git a/crates/locallibrary/src/bin/test-plugin/main.rs b/crates/locallibrary/src/bin/test-plugin/main.rs
index a9603536..1e2c4092 100644
--- a/crates/locallibrary/src/bin/test-plugin/main.rs
+++ b/crates/locallibrary/src/bin/test-plugin/main.rs
@@ -11,9 +11,11 @@
//! command then answering that param as text; both first write the `blobs` (by name, their
//! bytes), `hard_links` and (on Unix) `links` they're given to the scratch folder; a provider
//! other than echo's answers as its `answer` setting says (see `Test::provide`); an analyzer
-//! named `order` answers fields `a` (null for a one-byte audio file) then `b`, or fails naming an
-//! empty audio file; and its exporter names the playlists it's
-//! given in a note, and would write to `outside`, or writes `extra` too without saying so first.
+//! named `heard` answers the fields written in the audio file as JSON (beat grids, phrases and
+//! waveforms too, writing the files its `blobs` give first), and one named `order` answers fields
+//! `a` (null for a one-byte audio file) then `b`, or fails naming an empty audio file; and its
+//! exporter names the playlists it's given in a note, and would write to `outside`, or writes
+//! `extra` too without saying so first.
//! `--manifest ` serves another manifest, `--protocol ` answers `hello` with
//! another protocol and stops, `--silent` never answers, `--wrong-fields` answers fields of the
//! wrong type, and `--without ` leaves out that feature's hooks, and the feature from
diff --git a/crates/locallibrary/src/bin/test-plugin/plugin.rs b/crates/locallibrary/src/bin/test-plugin/plugin.rs
index b75bb8eb..07f96879 100644
--- a/crates/locallibrary/src/bin/test-plugin/plugin.rs
+++ b/crates/locallibrary/src/bin/test-plugin/plugin.rs
@@ -160,11 +160,14 @@ impl Test {
});
}
if req.analyzer == "heard" {
- // The fields are what the test wrote in the audio file, as JSON.
+ // The fields are what the test wrote in the audio file, as JSON, once the files its
+ // `blobs` give are in the scratch folder (a waveform's).
let text =
std::fs::read_to_string(&req.track.audio_path).map_err(|e| RpcError::failed("io", e.to_string()))?;
- let fields = serde_json::from_str(&text).map_err(|e| RpcError::failed("parse", e.to_string()))?;
- return Ok(Analysis { fields });
+ let mut heard: Value = serde_json::from_str(&text).map_err(|e| RpcError::failed("parse", e.to_string()))?;
+ write_blobs(&req.context, &heard)?;
+ heard.as_object_mut().map(|o| o.remove("blobs"));
+ return Ok(Analysis { fields: params(heard)? });
}
if req.analyzer == "order" {
let audio = &req.track.audio_path;
diff --git a/crates/locallibrary/src/cue_edit.rs b/crates/locallibrary/src/cue_edit.rs
index 4d501253..4bb92627 100644
--- a/crates/locallibrary/src/cue_edit.rs
+++ b/crates/locallibrary/src/cue_edit.rs
@@ -427,12 +427,17 @@ struct Loaded {
/// `.../ANLZ0000.DAT` -> `.../ANLZ0000.EXT`.
fn ext_key(dat: &str) -> Option {
+ sibling_key(dat, "EXT")
+}
+
+/// `.../ANLZ0000.DAT` -> `.../ANLZ0000.`.
+pub(crate) fn sibling_key(dat: &str, ext: &str) -> Option {
let stem =
dat.len().checked_sub(4).filter(|&i| dat.is_char_boundary(i) && dat[i..].eq_ignore_ascii_case(".DAT"))?;
- Some(format!("{}.EXT", &dat[..stem]))
+ Some(format!("{}.{ext}", &dat[..stem]))
}
-fn read_optional(device: &Device, key: &str) -> Result>> {
+pub(crate) fn read_optional(device: &Device, key: &str) -> Result >> {
let p = device.checked_path(key)?;
match std::fs::read(&p) {
Ok(b) => Ok(Some(b)),
diff --git a/crates/locallibrary/src/edit.rs b/crates/locallibrary/src/edit.rs
index 7ecf6d00..fd7d2ed5 100644
--- a/crates/locallibrary/src/edit.rs
+++ b/crates/locallibrary/src/edit.rs
@@ -216,6 +216,15 @@ pub enum Edit {
tracks: TrackSelector,
image: ImageSource,
},
+ /// Write a beat grid, phrases or a waveform into the analysis files of the track of this
+ /// audio file (a device path, see [`crate::analysis_edit`]). Planned, a grid brings the
+ /// track's BPM with it (an `edit_tracks`); the journal records a waveform as staged on the
+ /// drive.
+ SetAnalysis {
+ track: String,
+ #[serde(flatten)]
+ analysis: crate::analysis_edit::NewAnalysis,
+ },
}
/// A history list as the journal names it.
@@ -1934,7 +1943,7 @@ impl<'a> Resolver<'a> {
.collect();
(vec![Op::DeleteHistory { lists: picked }], desc, Edit::DeleteHistoryLists { lists })
}
- Edit::MyTag(_) | Edit::Cues { .. } | Edit::SetArtwork { .. } => {
+ Edit::MyTag(_) | Edit::Cues { .. } | Edit::SetArtwork { .. } | Edit::SetAnalysis { .. } => {
unreachable!("planned as steps (see `Resolver::step`)")
}
Edit::DeleteHistoryLists { lists: wanted } => {
@@ -2018,8 +2027,28 @@ impl<'a> Resolver<'a> {
Edit::SetArtwork { tracks: TrackSelector::paths(files), image: ImageSource::Staged(hash) };
return Ok((canonical, desc));
}
+ Edit::SetAnalysis { track, analysis } => {
+ on_drive(std::slice::from_ref(track))?;
+ if analysis.is_empty() {
+ return Err(Error::invalid("say what to write: a beat grid, phrases or a waveform"));
+ }
+ let mut analysis = analysis.clone();
+ if let Some(wave) = &analysis.waveform {
+ let (hash, bytes) = crate::analysis_edit::stage(self.device, wave)?;
+ images.extend(bytes.map(|b| (hash, b)));
+ analysis.waveform = Some(crate::analysis_edit::WaveSource::Staged(hash));
+ }
+ // Replaying, it's checked as it's made, after the edits before it.
+ if !self.lenient {
+ for w in crate::analysis_edit::check(self.device, lib, track, &analysis, images)? {
+ self.note(w);
+ }
+ }
+ let desc = analysis.describe(track);
+ return Ok((Edit::SetAnalysis { track: track.clone(), analysis }, desc));
+ }
Edit::SetPath { track, path, .. } => format!("point {} at {path}", track.paths.join(", ")),
- _ => unreachable!("only My Tag, cue, artwork and pointing edits are steps"),
+ _ => unreachable!("only My Tag, cue, artwork, analysis and pointing edits are steps"),
};
Ok((edit.clone(), desc))
}
@@ -2192,11 +2221,13 @@ fn open_databases(device: &Device, warnings: &mut Vec) -> Result) -> Result {
plan_writing(device, edits, opts, &[], ctx)
}
@@ -2210,14 +2241,28 @@ pub(crate) fn plan_writing(
incoming: &[String],
ctx: &Ctx<'_>,
) -> Result {
+ let mut edits = edits.to_vec();
+ if !opts.lenient {
+ edits = edits.into_iter().flat_map(with_tempo).collect();
+ }
let extra = DriveContext::read(device, edits.iter().flat_map(Edit::selectors))?;
- plan_with(device, edits, opts, &extra, incoming, ctx)
+ plan_with(device, &edits, opts, &extra, incoming, ctx)
+}
+
+/// `edit`, and when it sets a beat grid, the track's BPM after it: the grid's tempo.
+fn with_tempo(edit: Edit) -> Vec {
+ let Edit::SetAnalysis { track, analysis } = &edit else { return vec![edit] };
+ let Some(bpm) = analysis.tempo() else { return vec![edit] };
+ let tracks = TrackSelector::paths([track.clone()]);
+ let tempo = Edit::EditTracks { tracks, fields: TrackFields { bpm: Some(bpm), ..TrackFields::default() } };
+ vec![edit, tempo]
}
impl Edit {
- /// A My Tag, cue or artwork edit: made on its own, before a plan's other edits (see [`plan`]).
+ /// A My Tag, cue, artwork or analysis edit: made on its own, before a plan's other edits (see
+ /// [`plan`]).
pub(crate) fn is_step(&self) -> bool {
- matches!(self, Edit::MyTag(_) | Edit::Cues { .. } | Edit::SetArtwork { .. })
+ matches!(self, Edit::MyTag(_) | Edit::Cues { .. } | Edit::SetArtwork { .. } | Edit::SetAnalysis { .. })
}
/// The track selections the edit makes.
@@ -2451,7 +2496,7 @@ fn plan_with(
let bytes: u64 = files.values().sum();
// Backup of the databases and deleted files, plus each new database staged next to the old,
// and each image artwork edits stage, with the two sizes made from it in each database (no
- // bigger than it).
+ // bigger than it; a staged waveform, which no database holds, is counted as one too).
let staged = images.values().map(|b| round(b.len() as u64)).sum::() * (1 + 2 * databases.len() as u64);
let needed_bytes = files.values().map(|&n| round(n)).sum::() + 2 * db_bytes + staged;
let free_space = fsutil::available_space(device.root());
@@ -2721,7 +2766,8 @@ struct Steps {
/// The other edits, planned again once those are made.
rest: Vec,
opts: EditOptions,
- /// The images artwork edits stage on the drive, by hash (those given staged are on it).
+ /// The images artwork edits and the waveforms analysis edits stage on the drive, by hash
+ /// (those given staged are on it).
images: FxHashMap>,
}
@@ -2741,6 +2787,9 @@ fn replay(plan: &EditPlan, steps: &Steps, device: &mut Device, ctx: &Ctx<'_>) ->
extra.extend(crate::cue_edit::analysis_files(device, &library, &track.paths))
}
Edit::SetArtwork { tracks, .. } => extra.extend(crate::artwork::image_files(device, &tracks.paths)?),
+ Edit::SetAnalysis { track, .. } => {
+ extra.extend(crate::analysis_edit::analysis_files(device, &library, track))
+ }
_ => {}
}
}
@@ -2814,8 +2863,11 @@ fn replay_steps(
Edit::SetArtwork { tracks, image: ImageSource::Staged(hash) } => {
crate::artwork::replay(device, &tracks.paths, hash, &steps.images, strict, ctx)
}
+ Edit::SetAnalysis { track, analysis } => {
+ crate::analysis_edit::replay(device, track, analysis, &steps.images, strict, ctx)
+ }
Edit::SetPath { .. } => replay_set_path(device, step, ctx),
- _ => unreachable!("only My Tag, cue, artwork (staged) and pointing edits are steps"),
+ _ => unreachable!("only My Tag, cue, artwork (staged), analysis and pointing edits are steps"),
};
if replayed.is_ok() || device.swaps() != before {
made.0.push(step.clone());
@@ -3221,9 +3273,10 @@ pub mod journal {
/// The journal's format. 1 was a bare list of entries; 2 is `{"version": 2, "entries": [...]}`
/// and its entries can hold edits older builds don't know (`set_entries`,
/// `delete_history_lists`, `my_tag`, `cues`); 3 adds `set_artwork`, and track details 2 would
- /// drop (`album_artist`, `bpm`, `isrc`, `release_date`), and is written only when an entry
- /// uses them. A journal of a later version is refused rather than misread or overwritten.
- pub const VERSION: u32 = 3;
+ /// drop (`album_artist`, `bpm`, `isrc`, `release_date`); 4 adds `set_analysis`. Each is
+ /// written only when an entry uses what it adds. A journal of a later version is refused
+ /// rather than misread or overwritten.
+ pub const VERSION: u32 = 4;
#[derive(Serialize)]
struct Versioned<'a> {
@@ -3263,36 +3316,49 @@ pub mod journal {
serde_json::from_value(entries).map_err(|e| Error::parse(&path, e))
}
- /// Write the journal, keeping only the staged images its artwork edits name.
+ /// Write the journal, keeping only the staged files its edits name.
fn write(device: &Device, entries: &[JournalEntry]) -> Result<()> {
let version = version(entries);
fsutil::write_json_atomic(&device.state_dir().join(FILE), &Versioned { version, entries })?;
- crate::artwork::prune_staged(device, &images(entries))
+ staged(entries).into_iter().try_for_each(|(kind, keep)| kind.prune(device, &keep))
}
- /// The version `entries` need: 3 once one holds an artwork edit or a track detail 3 added,
- /// else 2, so a build that reads 2 keeps editing a drive whose edits use neither.
+ /// The version `entries` need: 4 once one holds an analysis edit, 3 once one holds an artwork
+ /// edit or a track detail 3 added, else 2, so a build that reads an older one keeps editing a
+ /// drive whose edits use nothing newer.
fn version(entries: &[JournalEntry]) -> u32 {
- let needs_3 = |e: &Edit| match e {
- Edit::SetArtwork { .. } => true,
- Edit::EditTracks { fields, .. } => {
- fields.album_artist.is_some()
+ let needs = |e: &Edit| match e {
+ Edit::SetAnalysis { .. } => 4,
+ Edit::SetArtwork { .. } => 3,
+ Edit::EditTracks { fields, .. }
+ if fields.album_artist.is_some()
|| fields.bpm.is_some()
|| fields.isrc.is_some()
- || fields.release_date.is_some()
+ || fields.release_date.is_some() =>
+ {
+ 3
}
- _ => false,
+ _ => 2,
};
- if entries.iter().flat_map(|e| &e.edits).any(needs_3) { VERSION } else { 2 }
+ entries.iter().flat_map(|e| &e.edits).map(needs).max().unwrap_or(2)
}
- /// The staged images the entries' artwork edits name.
- fn images(entries: &[JournalEntry]) -> FxHashSet {
- let staged = |e: &Edit| match e {
+ /// The staged files the entries' edits name: artwork edits' images, analysis edits'
+ /// waveforms.
+ fn staged(entries: &[JournalEntry]) -> [(crate::staged::Staged, FxHashSet); 2] {
+ let edits = || entries.iter().flat_map(|e| &e.edits);
+ let images = edits().filter_map(|e| match e {
Edit::SetArtwork { image: ImageSource::Staged(hash), .. } => Some(*hash),
_ => None,
- };
- entries.iter().flat_map(|e| &e.edits).filter_map(staged).collect()
+ });
+ let waveforms = edits().filter_map(|e| match e {
+ Edit::SetAnalysis { analysis, .. } => match analysis.waveform {
+ Some(crate::analysis_edit::WaveSource::Staged(hash)) => Some(hash),
+ _ => None,
+ },
+ _ => None,
+ });
+ [(crate::staged::IMAGES, images.collect()), (crate::staged::WAVEFORMS, waveforms.collect())]
}
/// Record edits just made, after `backup`.
@@ -3336,12 +3402,12 @@ pub mod journal {
}
}
- /// Forget every recorded edit, with the images they staged: the drive got a whole library
+ /// Forget every recorded edit, with the files they staged: the drive got a whole library
/// from a snapshot (a push) or a pack, and they were made to the one it replaced. A journal
/// this build can't read goes too.
pub(crate) fn forget(device: &Device) -> Result<()> {
fsutil::remove_if_exists(&device.state_dir().join(FILE))?;
- crate::artwork::prune_staged(device, &FxHashSet::default())
+ staged(&[]).into_iter().try_for_each(|(kind, none)| kind.prune(device, &none))
}
/// Give `target` the recorded edits of `source`, whose library a sync just copied onto it: the
@@ -3351,7 +3417,9 @@ pub mod journal {
pub(crate) fn copy(source: &Device, target: &Device) -> Result<()> {
match list(source) {
Ok(entries) if !entries.is_empty() => {
- crate::artwork::copy_staged(source, target, &images(&entries))?;
+ for (kind, hashes) in staged(&entries) {
+ kind.copy(source, target, &hashes)?;
+ }
write(target, &entries)
}
_ => forget(target),
diff --git a/crates/locallibrary/src/fields.rs b/crates/locallibrary/src/fields.rs
index 1e0557fb..2ca65743 100644
--- a/crates/locallibrary/src/fields.rs
+++ b/crates/locallibrary/src/fields.rs
@@ -8,11 +8,15 @@
//! which versions they read: the plugin host's, those installed. A field may be null: worked out,
//! with nothing to show (a field left out wasn't worked out).
//!
+//! An analyzer's beat grid, phrases and waveform (structured fields) aren't columns: they're kept
+//! apart, in the [`overlay`] store.
+//!
//! Filters and sorts name a field by its plugin's id and its own (`sound.bpm`): a plugin's
//! analyzers never share a field name. [`TrackValues`] has a library's, for them. What
//! locallibrary itself reads (a track's key, its tempo, its level) it reads by role, whichever
//! analyzer has it ([`by_role`]).
+pub mod overlay;
pub mod roles;
use std::path::{Path, PathBuf};
@@ -28,6 +32,7 @@ use crate::{Error, Result, paths};
/// One field's value, and the types a plugin's manifest declares.
pub use loclib_plugin::{FieldType, FieldValue};
+pub use overlay::{Overlay, OverlayStore};
pub use roles::{Role, by_role};
/// An analyzer's fields for one track, by name.
diff --git a/crates/locallibrary/src/fields/overlay.rs b/crates/locallibrary/src/fields/overlay.rs
new file mode 100644
index 00000000..605d89af
--- /dev/null
+++ b/crates/locallibrary/src/fields/overlay.rs
@@ -0,0 +1,161 @@
+//! What analyzers work out for a track that isn't a column (feature `analysis`): a beat grid,
+//! phrases and a waveform. Kept beside their fields and keyed the same way (see
+//! [`super::FieldStore`]), in `/cache/overlay////`. Only
+//! drawn, where a track's drive has none, unless they're written to its analysis files
+//! (`plugins.apply_analysis`).
+
+use std::path::Path;
+
+use base64::prelude::*;
+use serde::{Deserialize, Serialize};
+
+use super::{Analyzer, is_name};
+use crate::anlz::{Bands, Beat, DETAIL_RATE, SongStructure, TrackAnalysis};
+use crate::audio_cache::{CACHE_DIR, Cache, Kind};
+use crate::store::Store;
+use crate::{Error, Result};
+
+/// One analyzer's structured outputs for one audio file.
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct Overlay {
+ pub beats: Option>,
+ pub phrases: Option,
+ /// At [`crate::anlz::DETAIL_RATE`] columns a second.
+ pub waveform: Option,
+}
+
+impl Overlay {
+ pub fn is_empty(&self) -> bool {
+ self.beats.is_none() && self.phrases.is_none() && self.waveform.is_none()
+ }
+}
+
+/// [`Overlay`] as its files hold it: the waveform packed, in base64.
+#[derive(Serialize, Deserialize)]
+struct Stored {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ beats: Option>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ phrases: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ waveform: Option,
+}
+
+impl Kind for Overlay {
+ const DIR: &'static str = "overlay";
+ const EXT: &'static str = "json";
+ const WHAT: &'static str = "a plugin's analysis";
+ /// The files' own format; the analyzer's version is in their folder.
+ const VERSION: u32 = 1;
+ type Value = Overlay;
+
+ fn encode(o: &Overlay) -> Vec {
+ let waveform = o.waveform.as_ref().map(|b| BASE64_STANDARD.encode(b.pack()));
+ let stored = Stored { beats: o.beats.clone(), phrases: o.phrases.clone(), waveform };
+ serde_json::to_vec(&stored).expect("an overlay serialises")
+ }
+
+ fn decode(bytes: &[u8]) -> Option {
+ let s: Stored = serde_json::from_slice(bytes).ok()?;
+ let waveform = match s.waveform {
+ Some(text) => Some(Bands::unpack(&BASE64_STANDARD.decode(text).ok()?)?),
+ None => None,
+ };
+ Some(Overlay { beats: s.beats, phrases: s.phrases, waveform })
+ }
+}
+
+/// What every analyzer plugin worked out besides its columns, `/cache/overlay`.
+pub struct OverlayStore {
+ root: std::path::PathBuf,
+}
+
+impl OverlayStore {
+ pub fn in_store(store: &Store) -> OverlayStore {
+ OverlayStore { root: store.root().join(CACHE_DIR).join(Overlay::DIR) }
+ }
+
+ fn of(&self, a: &Analyzer) -> Cache {
+ Cache::new(self.root.join(&a.plugin).join(&a.analyzer).join(&a.version))
+ }
+
+ /// Keep what `a` worked out for the audio file at `audio`.
+ pub fn put(&self, audio: &Path, a: &Analyzer, o: &Overlay) -> Result<()> {
+ if let Some(bad) = [&a.plugin, &a.analyzer, &a.version].into_iter().find(|s| !is_name(s)) {
+ return Err(Error::invalid(format!("{bad:?} isn't a plugin id, version or analyzer")));
+ }
+ let key = Cache::::key(audio).ok_or_else(|| Error::NotFound(audio.display().to_string()))?;
+ self.of(a).put(&key, o)
+ }
+
+ /// What `a` worked out for the audio file at `audio` as it is now, if it has.
+ pub fn get(&self, audio: &Path, a: &Analyzer) -> Option {
+ self.of(a).get(&Cache::::key(audio)?)
+ }
+
+ /// For drawing: each part from the first of `analyzers` with one for the audio file at
+ /// `audio`, and that analyzer.
+ pub fn first(&self, audio: &Path, analyzers: &[Analyzer]) -> Shown {
+ let mut shown = Shown::default();
+ for a in analyzers {
+ let Some(o) = self.get(audio, a) else { continue };
+ let from = || Some(a.clone());
+ if shown.overlay.beats.is_none() && o.beats.is_some() {
+ (shown.overlay.beats, shown.beats_from) = (o.beats.clone(), from());
+ }
+ if shown.overlay.phrases.is_none() && o.phrases.is_some() {
+ (shown.overlay.phrases, shown.phrases_from, shown.phrases_grid) = (o.phrases, from(), o.beats.clone());
+ }
+ if shown.overlay.waveform.is_none() && o.waveform.is_some() {
+ (shown.overlay.waveform, shown.waveform_from) = (o.waveform, from());
+ }
+ }
+ shown
+ }
+}
+
+/// An overlay put together from several analyzers, with the one each part is from.
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct Shown {
+ pub overlay: Overlay,
+ pub beats_from: Option,
+ pub phrases_from: Option,
+ /// The grid of the analyzer the phrases are from, whose beats they count.
+ pub phrases_grid: Option>,
+ pub waveform_from: Option,
+}
+
+impl Shown {
+ /// Fills in what a track's analysis from its drive (`a`) lacks: its grid, its phrases (only
+ /// on the grid they count), its waveforms. What it filled in, as a line to show
+ /// (`grid and waveform from echo.heard, not on the drive`), if anything.
+ pub fn fill(self, a: &mut TrackAnalysis) -> Option {
+ let mut filled: Vec<(&str, Analyzer)> = Vec::new();
+ if let (Some(beats), Some(from), true) = (self.overlay.beats, self.beats_from, a.beats.is_empty()) {
+ a.beats = beats;
+ filled.push(("grid", from));
+ }
+ let counts = self.phrases_grid.as_deref().is_none_or(|own| crate::plugin::same_grid(own, &a.beats));
+ if let (Some(phrases), Some(from), true) =
+ (self.overlay.phrases, self.phrases_from, a.structure.is_none() && counts)
+ {
+ a.structure = Some(phrases);
+ filled.push(("phrases", from));
+ }
+ if let (Some(bands), Some(from)) = (self.overlay.waveform, self.waveform_from)
+ && (a.preview.is_none() || a.detail.is_none())
+ {
+ a.preview = a.preview.take().or_else(|| Some(bands.pooled(1200).waveform(None)));
+ a.detail = a.detail.take().or_else(|| Some(bands.waveform(Some(DETAIL_RATE))));
+ filled.push(("waveform", from));
+ }
+ if filled.is_empty() {
+ return None;
+ }
+ let (parts, mut from): (Vec<&str>, Vec) =
+ filled.into_iter().map(|(part, a)| (part, format!("{}.{}", a.plugin, a.analyzer))).unzip();
+ from.dedup();
+ let from: Vec<&str> = from.iter().map(String::as_str).collect();
+ Some(format!("{} from {}, not on the drive", crate::op::and_list(&parts), crate::op::and_list(&from)))
+ }
+}
diff --git a/crates/locallibrary/src/lib.rs b/crates/locallibrary/src/lib.rs
index b4872b98..cfd281ec 100644
--- a/crates/locallibrary/src/lib.rs
+++ b/crates/locallibrary/src/lib.rs
@@ -55,6 +55,7 @@
//! everything else works, the profile document included; it just can't be published.
pub mod after_set;
+pub mod analysis_edit;
pub mod anlz;
pub mod api;
pub mod artwork;
@@ -121,6 +122,7 @@ pub mod settings;
pub mod smart;
pub mod sound;
pub mod space;
+mod staged;
pub mod store;
pub mod sync;
pub mod tags;
diff --git a/crates/locallibrary/src/op.rs b/crates/locallibrary/src/op.rs
index 1b3ff592..0e361a6e 100644
--- a/crates/locallibrary/src/op.rs
+++ b/crates/locallibrary/src/op.rs
@@ -230,6 +230,15 @@ pub struct CloneTarget {
pub left_alone: Option,
}
+/// Names for summaries: `a`, `a and b`, `a, b and c`.
+pub(crate) fn and_list(names: &[&str]) -> String {
+ match names {
+ [] => String::new(),
+ [a] => a.to_string(),
+ [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
+ }
+}
+
/// A size for summaries: `840 B`, `12.5 MB`.
pub fn bytes(n: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
diff --git a/crates/locallibrary/src/plugin.rs b/crates/locallibrary/src/plugin.rs
index dde0bd1f..5d66691d 100644
--- a/crates/locallibrary/src/plugin.rs
+++ b/crates/locallibrary/src/plugin.rs
@@ -16,9 +16,11 @@
//! (kept in the keychain); an operation's edits are planned and applied by the host as any edit
//! plan is, and an exporter's overwrites are confirmed first. Answers too big for a message are
//! files in a folder the host makes for each request (`scratch`), which it reads itself.
-//! Analyzers' values go to the [`crate::fields`] store. What providers find for tracks, the host
+//! Analyzers' values go to the [`crate::fields`] store, their beat grids, phrases and waveforms
+//! to its overlay store. What providers find for tracks, the host
//! offers the user and makes the edits of itself (see [`Plugins::provide`]).
+mod analysis;
mod analyzers;
mod apply;
mod blobs;
@@ -47,6 +49,7 @@ use crate::fields::roles::{Offered, RoleField};
use crate::keychain::Storage;
use crate::{Error, Result};
+pub use analysis::{AnalysisPart, ApplyAnalysis, same_grid};
pub use analyzers::Analyzed;
pub use apply::ApplyFields;
pub(crate) use blobs::abandoned as abandoned_scratch;
@@ -238,11 +241,14 @@ impl Plugins {
offered
}
- /// Every analyzer's fields, by name as filters and sorts name them (`sound.bpm`), with their
- /// types: in the order the plugins, and their analyzers, declare them.
+ /// Every analyzer's fields but its beat grids, phrases and waveforms (no columns), by name as
+ /// filters and sorts name them (`sound.bpm`), with their types: in the order the plugins, and
+ /// their analyzers, declare them.
pub fn fields(&self) -> crate::fields::Declared {
let all = self.manifests().flat_map(|m| m.contributes.analyzers.iter().map(move |a| (m, a)));
- all.flat_map(|(m, a)| a.fields.iter().map(move |(name, &kind)| (format!("{}.{name}", m.id), kind))).collect()
+ let columns =
+ all.flat_map(|(m, a)| a.fields.iter().map(move |(name, &kind)| (format!("{}.{name}", m.id), kind)));
+ columns.filter(|(_, kind)| !kind.is_structured()).collect()
}
/// Whether the plugin is running now. Never waits.
diff --git a/crates/locallibrary/src/plugin/analysis.rs b/crates/locallibrary/src/plugin/analysis.rs
new file mode 100644
index 00000000..dc8cc260
--- /dev/null
+++ b/crates/locallibrary/src/plugin/analysis.rs
@@ -0,0 +1,180 @@
+//! Writing what an analyzer worked out besides its columns to the tracks' analysis files
+//! (`plugins.apply_analysis`, feature `analysis`): its beat grid, phrases and waveform become an
+//! ordinary edit plan (each track's [`Edit::SetAnalysis`], with its BPM), shown and confirmed as
+//! any is, and journaled. The plugin isn't run: they're read from the
+//! [`crate::fields::overlay`] store, as it kept them.
+
+use base64::prelude::*;
+use loclib_plugin::FieldType;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+use super::Plugins;
+use crate::analysis_edit::{self, NewAnalysis, WaveSource};
+use crate::anlz::{self, Beat};
+use crate::api;
+use crate::edit::{self, Edit, EditOptions, EditPlan};
+use crate::fields::{Analyzer, OverlayStore};
+use crate::model::Track;
+use crate::op::{Planned, Touched};
+use crate::progress::Ctx;
+use crate::store::Store;
+use crate::{Error, Result, paths};
+
+/// A part of a track's analysis an analyzer can work out.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum AnalysisPart {
+ /// The beat grid (a `beats` field).
+ Grid,
+ /// The phrases (a `phrases` field).
+ Phrases,
+ /// The waveform (a `waveform` field).
+ Waveform,
+}
+
+impl AnalysisPart {
+ pub const ALL: [AnalysisPart; 3] = [AnalysisPart::Grid, AnalysisPart::Phrases, AnalysisPart::Waveform];
+
+ /// The type of the field it's in.
+ pub fn kind(self) -> FieldType {
+ match self {
+ AnalysisPart::Grid => FieldType::Beats,
+ AnalysisPart::Phrases => FieldType::Phrases,
+ AnalysisPart::Waveform => FieldType::Waveform,
+ }
+ }
+
+ /// As `--apply` names it.
+ pub fn name(self) -> &'static str {
+ match self {
+ AnalysisPart::Grid => "grid",
+ AnalysisPart::Phrases => "phrases",
+ AnalysisPart::Waveform => "waveform",
+ }
+ }
+}
+
+/// What to write to a drive's tracks' analysis files, from what one analyzer kept.
+#[derive(Debug, Clone, Deserialize, JsonSchema)]
+pub struct ApplyAnalysis {
+ pub plugin: String,
+ pub analyzer: String,
+ /// The library or drive whose tracks to write to.
+ pub drive: String,
+ /// What to write, which the analyzer must work out: `grid`, `phrases`, `waveform`.
+ pub parts: Vec,
+ /// Only these tracks, by id [default: every one].
+ #[serde(default)]
+ pub tracks: Option>,
+ /// Also over what a track's analysis files have: without it, only where they have none.
+ #[serde(default)]
+ pub overwrite: bool,
+}
+
+/// Whether two grids are the same beats (to a millisecond): phrases count one's beats as the
+/// other's.
+pub fn same_grid(a: &[Beat], b: &[Beat]) -> bool {
+ a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.time_ms.abs_diff(y.time_ms) <= 1)
+}
+
+impl Plugins {
+ /// Plans writing the beat grid, phrases and waveform (`req.parts`) an analyzer worked out to
+ /// the analysis files of the tracks of the drive `req.drive` names, as an edit plan
+ /// (`plugins.apply_analysis`): one `SetAnalysis` per track that would change, a grid with the
+ /// track's BPM. A track keeps what its files have unless `req.overwrite`; phrases counting
+ /// another grid's beats than the one the track ends up with are left out; tracks the analyzer
+ /// hasn't worked them out for yet, and those whose files can't take them (none, or
+ /// rekordbox's colour waveforms), are left out with a warning.
+ pub fn plan_apply_analysis(&self, req: &ApplyAnalysis, store: &Store, ctx: &Ctx<'_>) -> Result> {
+ let slot = self.slot(&req.plugin)?;
+ let declared = slot.declared("analyzer", &req.analyzer, &slot.manifest.contributes.analyzers, |a| &a.name)?;
+ if req.parts.is_empty() {
+ return Err(Error::invalid("say what to write: grid, phrases, waveform"));
+ }
+ if let Some(part) = req.parts.iter().find(|p| !declared.fields.values().any(|t| *t == p.kind())) {
+ return Err(Error::invalid(format!("{} works out no {}", declared.title, part.name())));
+ }
+ let device = api::resolve_drive(&req.drive)?;
+ let library = device.library()?;
+ let selected: Vec<&Track> = match &req.tracks {
+ Some(ids) => {
+ ids.iter().map(|&id| library.track(id).ok_or_else(|| Error::no_track(id))).collect::>()?
+ }
+ None => library.tracks.iter().collect(),
+ };
+ let analyzer =
+ Analyzer { plugin: req.plugin.clone(), analyzer: req.analyzer.clone(), version: declared.version.clone() };
+ let overlays = OverlayStore::in_store(store);
+ let wants = |p: AnalysisPart| req.parts.contains(&p);
+ let (mut edits, mut unanalysed, mut had, mut other_grid) = (Vec::new(), 0, 0, 0);
+ let mut refused: Vec = Vec::new();
+ for t in selected {
+ ctx.check()?;
+ let kept = device.checked_path(&paths::key(&t.file_path)).ok().and_then(|a| overlays.get(&a, &analyzer));
+ let Some(o) = kept else {
+ unanalysed += 1;
+ continue;
+ };
+ let drive = anlz::load(&device, t);
+ let mut a = NewAnalysis::default();
+ let mut take = |wanted: bool, given: bool, has: bool| {
+ let take = wanted && given && (req.overwrite || !has);
+ had += usize::from(wanted && given && !take);
+ take
+ };
+ if take(wants(AnalysisPart::Grid), o.beats.is_some(), !drive.beats.is_empty()) {
+ a.beats = o.beats.clone();
+ }
+ let grid = a.beats.as_deref().unwrap_or(&drive.beats);
+ let fits = o.beats.as_deref().is_none_or(|own| same_grid(own, grid));
+ if take(wants(AnalysisPart::Phrases), o.phrases.is_some(), drive.structure.is_some()) {
+ match fits {
+ true => a.phrases = o.phrases.clone(),
+ false => other_grid += 1,
+ }
+ }
+ let bands = o.waveform.as_ref().filter(|_| {
+ take(wants(AnalysisPart::Waveform), true, drive.preview.is_some() || drive.detail.is_some())
+ });
+ if a.is_empty() && bands.is_none() {
+ continue;
+ }
+ match analysis_edit::fits(&device, t, &a, bands) {
+ Ok((true, _)) => {}
+ // As it is already.
+ Ok((false, _)) => continue,
+ Err(e) => {
+ refused.push(e.to_string());
+ continue;
+ }
+ }
+ a.waveform = bands.map(|b| WaveSource::Base64(BASE64_STANDARD.encode(b.pack())));
+ edits.push(Edit::SetAnalysis { track: t.file_path.clone(), analysis: a });
+ }
+ let plan = edit::plan(&device, &edits, &EditOptions { lenient: false, no_journal: false }, ctx)?;
+ let mut planned = api::planned_edit("plugins.apply_analysis", plan, Touched::library(&device, true), ctx)?;
+ let what: Vec<&str> = AnalysisPart::ALL.into_iter().filter(|p| wants(*p)).map(AnalysisPart::name).collect();
+ planned
+ .summary
+ .insert(0, format!("write the {} from {} to {} track(s)", what.join(" and "), declared.title, edits.len()));
+ let title = &declared.title;
+ if unanalysed > 0 {
+ planned.warnings.push(format!("{unanalysed} track(s) aren't analysed by {title} yet"));
+ }
+ if had > 0 {
+ planned
+ .warnings
+ .push(format!("{had} of what's asked the tracks have already, kept: overwrite to replace it"));
+ }
+ if other_grid > 0 {
+ planned.warnings.push(format!(
+ "{other_grid} track(s)' phrases left out: they count the beats of {title}'s grid, not the track's"
+ ));
+ }
+ if let Some(first) = refused.first() {
+ planned.warnings.push(format!("{} track(s) left out, e.g. {first}", refused.len()));
+ }
+ Ok(planned)
+ }
+}
diff --git a/crates/locallibrary/src/plugin/analyzers.rs b/crates/locallibrary/src/plugin/analyzers.rs
index 0297d7f1..409eb88f 100644
--- a/crates/locallibrary/src/plugin/analyzers.rs
+++ b/crates/locallibrary/src/plugin/analyzers.rs
@@ -1,17 +1,26 @@
//! Plugins' analyzers (`track/analyze`): each track's audio file is handed to the plugin (read
//! only), a few at a time, and the fields it answers, once they're of the types the analyzer
-//! declares, are kept in the [`crate::fields`] store for the analyzer's version.
+//! declares, are kept in the [`crate::fields`] store for the analyzer's version; its beat grid,
+//! phrases and waveform (feature `analysis`) in the [`crate::fields::overlay`] store beside it,
+//! the waveform read from the file its answer names in the request's scratch folder.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, PoisonError};
-use loclib_plugin::{Analysis, AnalyzeTrack, Context, Track, method};
+use std::io::Read;
+
+use indexmap::IndexMap;
+
+use loclib_plugin::{Analysis, AnalyzeTrack, Context, FieldType, Output, Track, method};
use schemars::JsonSchema;
use serde::Serialize;
use super::Plugins;
+use super::blobs::Scratch;
+use super::process::Slot;
+use crate::anlz::{self, Bands};
use crate::audio_cache::TrackFailure;
-use crate::fields::{Analyzer, FieldStore};
+use crate::fields::{Analyzer, FieldStore, Fields, Overlay, OverlayStore};
use crate::progress::{Ctx, Event, NoProgress, Phase};
use crate::store::Store;
use crate::{Error, Result, paths};
@@ -59,7 +68,7 @@ impl Plugins {
}
None => library.tracks.iter().collect::>(),
};
- let fields = FieldStore::in_store(store);
+ let (fields, overlays) = (FieldStore::in_store(store), OverlayStore::in_store(store));
let key = Analyzer { plugin: plugin.into(), analyzer: analyzer.into(), version: declared.version.clone() };
let mut done = Analyzed::default();
let mut todo = Vec::new();
@@ -94,7 +103,7 @@ impl Plugins {
let Some(track) = todo.get(i) else { break };
ctx.emit(Event::Item { phase: Phase::Analyze, path: track.file_path.clone(), bytes: 0 });
let req = AnalyzeTrack { analyzer: analyzer.into(), track: track.clone(), context: context.clone() };
- let answer = slot.call::(method::ANALYZE_TRACK, &req, Some(store), &quiet).and_then(|a| {
+ let read = |a: Analysis, scratch: &Scratch, _: &_| {
let wrong = a.fields.iter().find_map(|(name, v)| match declared.fields.get(name) {
None => Some(format!("its field {name} isn't one {analyzer} declares")),
Some(t) if !t.accepts(v) => {
@@ -110,14 +119,20 @@ impl Plugins {
return Err(slot.misbehaved(wrong));
}
// In the order the analyzer declares them (they're columns), whatever the
- // answer's.
- let mut fields = a.fields;
- fields.sort_by_cached_key(|name, _| declared.fields.get_index_of(name));
- Ok(fields)
- });
+ // answer's; the structured ones apart.
+ let (structured, mut columns): (Fields, Fields) =
+ a.fields.into_iter().partition(|(name, _)| declared.fields[name].is_structured());
+ columns.sort_by_cached_key(|name, _| declared.fields.get_index_of(name));
+ let overlay = slot.overlay(&structured, &declared.fields, scratch)?;
+ Ok((columns, overlay))
+ };
+ let answer = slot.call_with(method::ANALYZE_TRACK, &req, Some(store), &quiet, &|_| {}, read);
match answer {
- Ok(answer) => {
- fields.put(&track.audio_path, &key, &answer)?;
+ Ok((columns, overlay)) => {
+ fields.put(&track.audio_path, &key, &columns)?;
+ if !overlay.is_empty() {
+ overlays.put(&track.audio_path, &key, &overlay)?;
+ }
analyzed.fetch_add(1, Ordering::SeqCst);
}
Err(Error::Cancelled) => return Err(Error::Cancelled),
@@ -152,3 +167,42 @@ impl Plugins {
Ok(done)
}
}
+
+impl Slot {
+ /// An analyzer's structured fields as the overlay store keeps them (of the types `declared`
+ /// says, checked already): its waveform read from `scratch` and put at 150 columns a second,
+ /// its beat grid one the analysis files can hold. Null ones are left out.
+ fn overlay(
+ &self,
+ structured: &Fields,
+ declared: &IndexMap,
+ scratch: &Scratch,
+ ) -> Result {
+ let mut o = Overlay::default();
+ for (name, value) in structured {
+ let output =
+ Output::of(declared[name], value).map_err(|why| self.misbehaved(format!("its {name}: {why}")))?;
+ match output {
+ None => {}
+ Some(Output::Beats(beats)) if beats.is_empty() => {}
+ Some(Output::Beats(beats)) => {
+ let checked =
+ anlz::checked_grid(&beats).map_err(|e| self.misbehaved(format!("its {name}: {e}")))?;
+ o.beats = Some(checked);
+ }
+ Some(Output::Phrases(phrases)) => o.phrases = Some(phrases),
+ Some(Output::Waveform(w)) => {
+ let mut bytes = Vec::new();
+ let read =
+ scratch.blob(&w.blob).and_then(|mut f| f.read_to_end(&mut bytes).map_err(|e| e.to_string()));
+ read.map_err(|why| self.misbehaved(format!("its {name}: {why}")))?;
+ let bands = Bands::unpack(&bytes).filter(|b| !b.0.is_empty()).ok_or_else(|| {
+ self.misbehaved(format!("its {name} isn't three bytes a column, at least one column"))
+ })?;
+ o.waveform = Some(anlz::at_detail_rate(&bands, w.columns_per_sec));
+ }
+ }
+ }
+ Ok(o)
+ }
+}
diff --git a/crates/locallibrary/src/plugin/operations.rs b/crates/locallibrary/src/plugin/operations.rs
index 8496c968..5c25fd45 100644
--- a/crates/locallibrary/src/plugin/operations.rs
+++ b/crates/locallibrary/src/plugin/operations.rs
@@ -1,8 +1,8 @@
//! Plugins' operations (`op/plan`): a plugin answers edits, which the host plans as one edit plan
//! on the drive the user is on, and applies as it applies any (locked, checked against the drive
//! and the files the plugin read as planned, backed up first and journaled). The plugin never
-//! writes to the drive. An image an edit gives is a file in the request's scratch folder
-//! (`{"blob": "cover.jpg"}`), which the host reads as it plans. It may answer a question for the
+//! writes to the drive. An image or a waveform an edit gives is a file in the request's scratch
+//! folder (`{"blob": "cover.jpg"}`), which the host reads as it plans. It may answer a question for the
//! user instead (`{"choose": ...}`, the `choose` feature), after which the host plans it again with
//! what was chosen.
@@ -17,6 +17,7 @@ use serde_json::Value;
use super::Plugins;
use super::blobs::Scratch;
use super::process::{Live, Slot};
+use crate::analysis_edit::WaveSource;
use crate::api;
use crate::artwork::ImageSource;
use crate::edit::{self, Edit, EditOptions, EditPlan};
@@ -141,8 +142,8 @@ impl Slot {
}
/// The plugin's edit `i`, once it may make it: one the host can read, of a kind its
- /// capabilities grant, and giving an image only as a file in `scratch`, which the host reads
- /// as it plans.
+ /// capabilities grant, and giving an image or a waveform only as a file in `scratch`, which
+ /// the host reads as it plans.
fn edit(&self, i: usize, edit: Value, scratch: &Scratch) -> Result {
let mut edit: Edit =
serde_json::from_value(edit).map_err(|e| self.misbehaved(format!("its edit {i} isn't one: {e}")))?;
@@ -150,22 +151,41 @@ impl Slot {
Edit::MyTag(_) => Capability::TagsPlan,
Edit::Cues { .. } => Capability::CuesPlan,
Edit::SetArtwork { .. } => Capability::ArtworkPlan,
+ Edit::SetAnalysis { .. } => Capability::AnalysisPlan,
_ => Capability::LibraryPlan,
};
if !self.manifest.capabilities.contains(&needs) {
return Err(self.misbehaved(format!("its edit {i} needs the {needs} capability, which it doesn't have")));
}
- if let Edit::SetArtwork { image, .. } = &mut edit {
- let ImageSource::Blob(name) = image else {
+ // A file named in its scratch folder, read now: the plugin can't change it once it's
+ // checked.
+ let blob = |what: &str, given: Option<&str>| -> Result {
+ let Some(name) = given else {
return Err(self.misbehaved(format!(
- "its edit {i} gives an image otherwise than as a file in its scratch folder ({{\"blob\": name}})"
+ "its edit {i} gives {what} otherwise than as a file in its scratch folder ({{\"blob\": name}})"
)));
};
let mut bytes = Vec::new();
let read = scratch.blob(name).and_then(|mut f| f.read_to_end(&mut bytes).map_err(|e| e.to_string()));
read.map_err(|why| self.misbehaved(format!("its edit {i}: {why}")))?;
- // Its bytes, read now: the plugin can't change the file it named once it's checked.
- *image = ImageSource::Base64(BASE64_STANDARD.encode(bytes));
+ Ok(BASE64_STANDARD.encode(bytes))
+ };
+ match &mut edit {
+ Edit::SetArtwork { image, .. } => {
+ let name = match image {
+ ImageSource::Blob(name) => Some(name.as_str()),
+ _ => None,
+ };
+ *image = ImageSource::Base64(blob("an image", name)?);
+ }
+ Edit::SetAnalysis { analysis, .. } if analysis.waveform.is_some() => {
+ let name = match &analysis.waveform {
+ Some(WaveSource::Blob(name)) => Some(name.as_str()),
+ _ => None,
+ };
+ analysis.waveform = Some(WaveSource::Base64(blob("a waveform", name)?));
+ }
+ _ => {}
}
Ok(edit)
}
diff --git a/crates/locallibrary/src/profile/mod.rs b/crates/locallibrary/src/profile/mod.rs
index 8ee25709..8c4704ed 100644
--- a/crates/locallibrary/src/profile/mod.rs
+++ b/crates/locallibrary/src/profile/mod.rs
@@ -1370,14 +1370,6 @@ fn pct(x: f64) -> String {
format!("{:.0}%", x * 100.0)
}
-fn and_list(names: &[&str]) -> String {
- match names {
- [] => String::new(),
- [a] => a.to_string(),
- [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
- }
-}
-
/// "122–128 BPM" (or "128 BPM").
pub fn bpm_range(lo: f64, hi: f64) -> String {
let (lo, hi) = (lo.round(), hi.round());
@@ -1391,7 +1383,7 @@ pub fn headline(r: &ProfileReport, shows: impl Fn(Section) -> bool, count: impl
let mut parts = Vec::new();
let genres: Vec<&str> = r.genres.top.iter().filter(|s| s.share >= 0.1).take(2).map(|s| s.name.as_str()).collect();
if !genres.is_empty() && shows(Section::Genres) {
- parts.push(and_list(&genres));
+ parts.push(crate::op::and_list(&genres));
}
if let Some(core) = r.tempo.core
&& shows(Section::Tempo)
diff --git a/crates/locallibrary/src/query.rs b/crates/locallibrary/src/query.rs
index b5a6a108..1095c5cb 100644
--- a/crates/locallibrary/src/query.rs
+++ b/crates/locallibrary/src/query.rs
@@ -603,8 +603,9 @@ impl Term {
text.iter().any(|w| has_words(&s, w))
};
match ctx.fields.and_then(|f| f.get(t.id, name)) {
- // Worked out, with nothing to show: it matches no comparison.
- None | Some(FieldValue::Null) => false,
+ // Worked out, with nothing to show: it matches no comparison. Nor does a
+ // beat grid, phrases or a waveform, which are no columns.
+ None | Some(FieldValue::Null | FieldValue::Structured(_)) => false,
Some(FieldValue::Integer(v)) => num.is_some_and(|n| n.test(*v as f64)),
Some(FieldValue::Number(v)) => num.is_some_and(|n| n.test(*v)),
Some(FieldValue::Boolean(b)) => {
diff --git a/crates/locallibrary/src/staged.rs b/crates/locallibrary/src/staged.rs
new file mode 100644
index 00000000..36c35fe5
--- /dev/null
+++ b/crates/locallibrary/src/staged.rs
@@ -0,0 +1,56 @@
+//! Files edits keep on a drive for its journal to name, by hash, below its state folder
+//! (`.locallibrary//.`): the images artwork edits set and the waveforms
+//! analysis edits write, so replaying the journal after rekordbox exports makes the same edit.
+//! The journal keeps those its entries name ([`Staged::prune`]) and takes them along when its
+//! library goes to another drive ([`Staged::copy`]).
+
+use rustc_hash::FxHashSet;
+use std::path::PathBuf;
+
+use crate::Result;
+use crate::device::Device;
+use crate::fsutil;
+use crate::hash::Hash;
+use crate::progress::Ctx;
+
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct Staged {
+ folder: &'static str,
+ ext: &'static str,
+}
+
+/// Artwork edits' images: JPEGs of the larger size players show.
+pub(crate) const IMAGES: Staged = Staged { folder: "artwork", ext: "jpg" };
+
+/// Analysis edits' waveforms: three bands packed ([`crate::anlz::Bands`]), 150 columns a second.
+pub(crate) const WAVEFORMS: Staged = Staged { folder: "waveforms", ext: "bands" };
+
+impl Staged {
+ pub(crate) fn path(self, device: &Device, hash: &Hash) -> PathBuf {
+ device.state_dir().join(self.folder).join(format!("{hash}.{}", self.ext))
+ }
+
+ /// Keep only the files in `keep`.
+ pub(crate) fn prune(self, device: &Device, keep: &FxHashSet) -> Result<()> {
+ let Ok(entries) = std::fs::read_dir(device.state_dir().join(self.folder)) else { return Ok(()) };
+ for e in entries.flatten() {
+ let name = e.file_name().to_string_lossy().into_owned();
+ let named = name.strip_suffix(&format!(".{}", self.ext)).and_then(|h| h.parse::().ok());
+ if !named.is_some_and(|h| keep.contains(&h)) {
+ fsutil::remove_if_exists(&e.path())?;
+ }
+ }
+ Ok(())
+ }
+
+ /// Give `target` the files in `hashes` that `source` has and it lacks.
+ pub(crate) fn copy(self, source: &Device, target: &Device, hashes: &FxHashSet) -> Result<()> {
+ for hash in hashes {
+ let (from, to) = (self.path(source, hash), self.path(target, hash));
+ if from.is_file() && !to.is_file() {
+ fsutil::copy_file(&from, &to, None, Some(*hash), true, &Ctx::silent())?;
+ }
+ }
+ Ok(())
+ }
+}
diff --git a/crates/locallibrary/tests/it/analysis_edit.rs b/crates/locallibrary/tests/it/analysis_edit.rs
new file mode 100644
index 00000000..d7445740
--- /dev/null
+++ b/crates/locallibrary/tests/it/analysis_edit.rs
@@ -0,0 +1,361 @@
+//! Beat grids, phrases and waveforms set as edits: written into the track's analysis files with
+//! its BPM, the rest of each file as it was, recorded with a waveform staged on the drive, taken
+//! back by undo, made again by a replay after rekordbox exports, and refused where they can't be
+//! stored.
+
+use crate::common;
+
+use std::path::Path;
+
+use base64::prelude::*;
+use hegel::generators as gs;
+use hegel::{HealthCheck, TestCase};
+use locallibrary::analysis_edit::{NewAnalysis, WaveSource};
+use locallibrary::anlz::{
+ self, Bands, Beat, CueFile, CuePoint, Mood, Phrase, PhraseKind, SongStructure, WaveformStyle,
+};
+use locallibrary::device::Device;
+use locallibrary::edit::{self, Edit, EditOptions, EditPlan, TrackFields};
+use locallibrary::model::Track;
+use locallibrary::progress::Ctx;
+use locallibrary::query::TrackSelector;
+use locallibrary::{Hash, backup, paths};
+
+fn plan(root: &Path, edits: &[Edit]) -> locallibrary::Result {
+ edit::plan(&Device::open_export(root).unwrap(), edits, &EditOptions::default(), &Ctx::silent())
+}
+
+fn set(t: &Track, analysis: NewAnalysis) -> Edit {
+ Edit::SetAnalysis { track: t.file_path.clone(), analysis }
+}
+
+fn track(root: &Path, title: &str) -> Track {
+ let lib = Device::open_export(root).unwrap().library().unwrap();
+ lib.tracks.iter().find(|t| t.title == title).unwrap_or(&lib.tracks[0]).clone()
+}
+
+/// A track's analysis files on the drive: `.DAT`, `.EXT` and `.2EX`, those it has.
+fn files(root: &Path, t: &Track) -> [Option>; 3] {
+ let dat = paths::to_fs(root, &paths::key(&t.analyze_path));
+ ["DAT", "EXT", "2EX"].map(|e| std::fs::read(dat.with_extension(e)).ok())
+}
+
+/// `beats` beats at `bpm`, the first at `from` ms.
+fn steady(beats: usize, bpm: f64, from: u32) -> Vec {
+ (0..beats)
+ .map(|i| Beat { beat_number: (i % 4) as u16 + 1, bpm, time_ms: from + (i as f64 * 60_000.0 / bpm) as u32 })
+ .collect()
+}
+
+/// A grid set on a rekordbox 6 export's track: its `.DAT` holds it and the databases its tempo,
+/// the `.EXT` loses its extended grid (saying so) and nothing else changes; recorded with the
+/// BPM edit after it, in a journal of version 4. Undo puts the files and the BPM back, and a
+/// replay after rekordbox exports again makes it again.
+#[test]
+fn a_beat_grid_is_written_with_its_bpm_undone_and_replayed() {
+ let dir = tempfile::tempdir().unwrap();
+ let root = common::demo_drive(dir.path(), "USB");
+ let exported = common::library_files(&root);
+ let t = track(&root, "Demo Track 1");
+ let before = files(&root, &t);
+ let beats = steady(300, 125.0, 40);
+ let edit = set(&t, NewAnalysis { beats: Some(beats.clone()), ..Default::default() });
+ let p = plan(&root, std::slice::from_ref(&edit)).unwrap();
+ assert_eq!(p.changes[0], format!("set the beat grid of {}", t.file_path));
+ assert!(p.warnings.iter().any(|w| w.contains("extended beat grid (PQT2)")), "{:?}", p.warnings);
+ let tempo = Edit::EditTracks {
+ tracks: TrackSelector::paths([t.file_path.clone()]),
+ fields: TrackFields { bpm: Some(125.0), ..Default::default() },
+ };
+ assert_eq!(p.recorded, [edit, tempo]);
+ let mut d = Device::open_export(&root).unwrap();
+ edit::apply(&p, &mut d, &Ctx::silent()).unwrap();
+
+ let after = files(&root, &t);
+ let [dat, ext, _] = &after;
+ assert_eq!(anlz::beat_grid(dat.as_ref().unwrap()).unwrap(), beats);
+ assert!(!anlz::has_section(ext.as_ref().unwrap(), b"PQT2").unwrap());
+ assert_eq!(after[2], before[2], "the .2EX holds no grid");
+ for (a, b) in after.iter().zip(&before) {
+ let (a, b) = (a.as_deref().unwrap(), b.as_deref().unwrap());
+ assert_eq!(anlz::song_structure(a).unwrap(), anlz::song_structure(b).unwrap());
+ assert_eq!(anlz::read_cue_points(a, None).unwrap(), anlz::read_cue_points(b, None).unwrap());
+ assert_eq!(anlz::detail(a).unwrap(), anlz::detail(b).unwrap());
+ }
+ assert_eq!(track(&root, "Demo Track 1").bpm, 125.0);
+ let journal = std::fs::read(root.join(paths::STATE_DIR).join("edits.json")).unwrap();
+ assert_eq!(serde_json::from_slice::(&journal).unwrap()["version"], 4);
+
+ backup::restore(&mut d, None, false).unwrap();
+ assert_eq!(files(&root, &t), before);
+ assert_eq!(track(&root, "Demo Track 1").bpm, t.bpm);
+
+ // Applied again, then rekordbox exports over it: a replay writes the same files again.
+ let p = plan(&root, &[set(&t, NewAnalysis { beats: Some(beats), ..Default::default() })]).unwrap();
+ edit::apply(&p, &mut Device::open_export(&root).unwrap(), &Ctx::silent()).unwrap();
+ let edited = files(&root, &t);
+ for (key, bytes) in &exported {
+ std::fs::write(paths::to_fs(&root, key), bytes).unwrap();
+ }
+ assert_eq!(files(&root, &t), before);
+ common::replay(&root);
+ assert_eq!(files(&root, &t), edited);
+ assert_eq!(track(&root, "Demo Track 1").bpm, 125.0);
+}
+
+/// Phrases set on a track go into its `.EXT`, as given, and nothing else there changes.
+#[test]
+fn phrases_are_written_into_the_ext() {
+ let dir = tempfile::tempdir().unwrap();
+ let root = common::demo_drive(dir.path(), "USB");
+ let t = track(&root, "Demo Track 2");
+ let before = files(&root, &t);
+ let phrase = |kind, start_beat, end_beat| Phrase { kind, start_beat, end_beat, fill_beat: None };
+ let phrases = SongStructure {
+ mood: Mood::High,
+ phrases: vec![
+ phrase(PhraseKind::Intro, 1, 65),
+ Phrase { fill_beat: Some(125), ..phrase(PhraseKind::Up, 65, 129) },
+ ],
+ };
+ let p = plan(&root, &[set(&t, NewAnalysis { phrases: Some(phrases.clone()), ..Default::default() })]).unwrap();
+ assert_eq!(p.recorded.len(), 1, "no tempo without a grid");
+ edit::apply(&p, &mut Device::open_export(&root).unwrap(), &Ctx::silent()).unwrap();
+ let after = files(&root, &t);
+ assert_eq!(after[0], before[0]);
+ assert_eq!(anlz::song_structure(after[1].as_ref().unwrap()).unwrap(), Some(phrases));
+ let others =
+ |b: &[u8]| (anlz::detail(b).unwrap(), anlz::preview(b).unwrap(), anlz::read_cue_points(b, None).unwrap());
+ assert_eq!(others(after[1].as_ref().unwrap()), others(before[1].as_ref().unwrap()));
+}
+
+/// The demo drive with a track's analysis files without rekordbox's colour overview (`PWV4`) and
+/// the `.2EX`.
+fn without_colour_overview(dir: &Path) -> (std::path::PathBuf, Track) {
+ let root = common::demo_drive(dir, "USB");
+ let t = track(&root, "Demo Track 1");
+ let dat = paths::to_fs(&root, &paths::key(&t.analyze_path));
+ let ext = std::fs::read(dat.with_extension("EXT")).unwrap();
+ let be = |at: usize| u32::from_be_bytes(ext[at..at + 4].try_into().unwrap()) as usize;
+ let mut pos = be(4);
+ while &ext[pos..pos + 4] != b"PWV4" {
+ pos += be(pos + 8);
+ }
+ let mut without = [&ext[..pos], &ext[pos + be(pos + 8)..]].concat();
+ let len = without.len() as u32;
+ without[8..12].copy_from_slice(&len.to_be_bytes());
+ std::fs::write(dat.with_extension("EXT"), without).unwrap();
+ std::fs::remove_file(dat.with_extension("2EX")).unwrap();
+ (root, t)
+}
+
+fn bands(n: usize) -> Bands {
+ Bands((0..n).map(|i| [(i % 256) as u8, 128, (255 - i % 256) as u8]).collect())
+}
+
+fn wave(b: &Bands) -> WaveSource {
+ WaveSource::Base64(BASE64_STANDARD.encode(b.pack()))
+}
+
+/// rekordbox's colour overview (`PWV4`) can't be worked out from another waveform, so a track
+/// with one keeps its waveforms: the plan is refused, saying why.
+#[test]
+fn a_waveform_is_refused_for_a_track_with_rekordboxs_colour_overview() {
+ let dir = tempfile::tempdir().unwrap();
+ let root = common::demo_drive(dir.path(), "USB");
+ let t = track(&root, "Demo Track 1");
+ let err = plan(&root, &[set(&t, NewAnalysis { waveform: Some(wave(&bands(150))), ..Default::default() })]);
+ let err = err.unwrap_err().to_string();
+ assert!(err.contains("(PWV4) that can't be worked out from another waveform"), "{err}");
+}
+
+/// A waveform set on a track without rekordbox's colour one goes into every waveform tag its
+/// files take, worked out from the bands; it's recorded staged on the drive by its hash, and a
+/// replay after rekordbox exports writes the same files from the staged copy.
+#[test]
+fn a_waveform_is_written_staged_and_replayed() {
+ let dir = tempfile::tempdir().unwrap();
+ let (root, t) = without_colour_overview(dir.path());
+ let exported = common::library_files(&root);
+ let b = bands(30 * 150);
+ let garbled = NewAnalysis { waveform: Some(WaveSource::Base64("AAA=".into())), ..Default::default() };
+ let err = plan(&root, &[set(&t, garbled)]).unwrap_err().to_string();
+ assert_eq!(err, "a waveform is three bytes a column (lows, mids, highs), at least one column");
+ let p = plan(&root, &[set(&t, NewAnalysis { waveform: Some(wave(&b)), ..Default::default() })]).unwrap();
+ let hash = Hash::of(&b.pack());
+ let [Edit::SetAnalysis { analysis, .. }] = &p.recorded[..] else { panic!("{:?}", p.recorded) };
+ assert_eq!(analysis.waveform, Some(WaveSource::Staged(hash)));
+ let staged = root.join(paths::STATE_DIR).join("waveforms").join(format!("{hash}.bands"));
+ assert!(!staged.exists(), "nothing on the drive until it's applied");
+ assert!(p.warnings.iter().any(|w| w.contains("waveform is 30 s long")), "{:?}", p.warnings);
+ edit::apply(&p, &mut Device::open_export(&root).unwrap(), &Ctx::silent()).unwrap();
+ assert_eq!(std::fs::read(&staged).unwrap(), b.pack());
+
+ let edited = files(&root, &t);
+ let (dat, ext) = (edited[0].as_ref().unwrap(), edited[1].as_ref().unwrap());
+ let preview = anlz::preview(dat).unwrap().unwrap();
+ assert_eq!((preview.style, preview.columns.len()), (WaveformStyle::Blue, 400));
+ let detail = anlz::detail(ext).unwrap().unwrap();
+ assert_eq!(
+ (detail.style, detail.columns.len(), detail.columns_per_sec),
+ (WaveformStyle::Rgb, b.0.len(), Some(150.0))
+ );
+ assert_eq!(anlz::waveform_entries(ext, b"PWV3").unwrap().unwrap().len(), b.0.len());
+ assert_eq!(anlz::waveform_entries(dat, b"PWV2").unwrap().unwrap().len(), 100);
+
+ for (key, bytes) in &exported {
+ std::fs::write(paths::to_fs(&root, key), bytes).unwrap();
+ }
+ common::replay(&root);
+ assert_eq!(files(&root, &t), edited);
+ // Forgotten with the journal.
+ edit::journal::clear(&Device::open_export(&root).unwrap(), None).unwrap();
+ assert!(!staged.exists());
+}
+
+/// Moving the grid leaves cues where they are, saying how many sat on beats of the old one.
+#[test]
+fn cues_on_beats_stay_put_when_the_grid_moves() {
+ let dir = tempfile::tempdir().unwrap();
+ let root = common::demo_drive(dir.path(), "USB");
+ let t = track(&root, "Demo Track 1");
+ let [dat, ext, _] = files(&root, &t);
+ let (dat, ext) = (dat.unwrap(), ext.unwrap());
+ let grid = anlz::beat_grid(&dat).unwrap();
+ let cues = [
+ CuePoint { hot_cue: Some(0), start_ms: grid[4].time_ms, ..Default::default() },
+ CuePoint { start_ms: grid[8].time_ms + 100, ..Default::default() },
+ ];
+ let at = paths::to_fs(&root, &paths::key(&t.analyze_path));
+ std::fs::write(&at, anlz::write_cue_points(&dat, CueFile::Dat, &cues).unwrap()).unwrap();
+ std::fs::write(at.with_extension("EXT"), anlz::write_cue_points(&ext, CueFile::Ext, &cues).unwrap()).unwrap();
+ let moved: Vec = grid.iter().map(|b| Beat { time_ms: b.time_ms + 20, ..*b }).collect();
+ let p = plan(&root, &[set(&t, NewAnalysis { beats: Some(moved), ..Default::default() })]).unwrap();
+ let said = format!(
+ "1 cue(s) of {} sat on beats of its old grid: they stay where they are, off the new one",
+ t.display_name()
+ );
+ assert!(p.warnings.contains(&said), "{:?}", p.warnings);
+ edit::apply(&p, &mut Device::open_export(&root).unwrap(), &Ctx::silent()).unwrap();
+ let after = files(&root, &t);
+ let back = anlz::read_cue_points(after[0].as_ref().unwrap(), after[1].as_deref()).unwrap();
+ assert_eq!(back.iter().map(|c| c.start_ms).collect::>(), [grid[8].time_ms + 100, grid[4].time_ms]);
+}
+
+/// What can't be written is refused before anything is: nothing to write, a track without
+/// analysis files, phrases for a track without an `.EXT`, a beat past the track's end.
+#[test]
+fn analysis_that_cant_be_written_is_refused() {
+ let dir = tempfile::tempdir().unwrap();
+ let root = common::demo_drive(dir.path(), "USB");
+ let t = track(&root, "Demo Track 2");
+ let refused = |t: &Track, a: NewAnalysis| plan(&root, &[set(t, a)]).unwrap_err().to_string();
+ assert_eq!(refused(&t, NewAnalysis::default()), "say what to write: a beat grid, phrases or a waveform");
+ let past = steady(4, 120.0, (t.duration_secs + 2) * 1000);
+ let err = refused(&t, NewAnalysis { beats: Some(past), ..Default::default() });
+ assert!(
+ err.starts_with("beat 1 at ") && err.ends_with(&format!("is past the end of {}", t.display_name())),
+ "{err}"
+ );
+ let dat = paths::to_fs(&root, &paths::key(&t.analyze_path));
+ std::fs::remove_file(dat.with_extension("EXT")).unwrap();
+ let phrases = SongStructure {
+ mood: Mood::Mid,
+ phrases: vec![Phrase { kind: PhraseKind::Intro, start_beat: 1, end_beat: 9, fill_beat: None }],
+ };
+ let err = refused(&t, NewAnalysis { phrases: Some(phrases), ..Default::default() });
+ assert_eq!(err, format!("{} has no .EXT analysis file to hold phrases", t.display_name()));
+ std::fs::remove_file(&dat).unwrap();
+ let err = refused(&t, NewAnalysis { beats: Some(steady(4, 120.0, 0)), ..Default::default() });
+ assert_eq!(err, format!("{} has no .DAT analysis file", t.display_name()));
+ let gone = Edit::SetAnalysis { track: "/Contents/nowhere.mp3".into(), analysis: NewAnalysis::default() };
+ assert!(plan(&root, &[gone]).unwrap_err().to_string().contains("/Contents/nowhere.mp3"));
+}
+
+/// Any grid and phrases set on a rekordbox 6 export's track read back as given, with the tempo
+/// most beats have in the databases, and so does any waveform on a track without rekordbox's
+/// colour one, staged on the drive; undo puts every file back.
+#[hegel::test(test_cases = 8, suppress_health_check = [HealthCheck::TooSlow])]
+fn any_grid_phrases_and_waveform_read_back_and_undo(tc: TestCase) {
+ let dir = tempfile::tempdir().unwrap();
+ let with_wave = tc.draw(gs::booleans());
+ let (root, t) = match with_wave {
+ true => without_colour_overview(dir.path()),
+ false => {
+ let root = common::demo_drive(dir.path(), "USB");
+ let t = track(&root, "Demo Track 2");
+ (root, t)
+ }
+ };
+ let before = files(&root, &t);
+ let n = tc.draw(gs::integers::().min_value(8).max_value(200));
+ let bpms: Vec =
+ tc.draw(gs::vecs(gs::integers::().min_value(6_000).max_value(18_000)).min_size(1).max_size(3));
+ let beats: Vec = (0..n)
+ .map(|i| {
+ let bpm = f64::from(bpms[i * bpms.len() / n]) / 100.0;
+ Beat { beat_number: (i % 4) as u16 + 1, bpm, time_ms: 10 + i as u32 * 400 }
+ })
+ .collect();
+ let ends: Vec = {
+ let mut e: Vec =
+ tc.draw(gs::vecs(gs::integers::().min_value(2).max_value(n as u16)).min_size(1).max_size(6));
+ e.sort_unstable();
+ e.dedup();
+ e
+ };
+ let kinds = [PhraseKind::Intro, PhraseKind::Verse(1), PhraseKind::Bridge, PhraseKind::Chorus, PhraseKind::Outro];
+ let mut start = 1;
+ let phrases = ends
+ .iter()
+ .map(|&end| {
+ let p = Phrase {
+ kind: tc.draw(gs::sampled_from(kinds.to_vec())),
+ start_beat: start,
+ end_beat: end,
+ fill_beat: None,
+ };
+ start = end;
+ p
+ })
+ .collect();
+ let phrases = SongStructure { mood: Mood::Low, phrases };
+ let columns = tc.draw(gs::integers::().min_value(1).max_value(900));
+ let bytes: Vec = tc.draw(gs::vecs(gs::integers::()).min_size(3 * columns).max_size(3 * columns));
+ let bands = Bands::unpack(&bytes).unwrap();
+ let waveform = with_wave.then(|| wave(&bands));
+ let analysis = NewAnalysis { beats: Some(beats.clone()), phrases: Some(phrases.clone()), waveform };
+ let p = plan(&root, &[set(&t, analysis.clone())]).unwrap();
+ let mut d = Device::open_export(&root).unwrap();
+ edit::apply(&p, &mut d, &Ctx::silent()).unwrap();
+ let after = files(&root, &t);
+ let (dat, ext) = (after[0].as_ref().unwrap(), after[1].as_ref().unwrap());
+ assert_eq!(anlz::beat_grid(dat).unwrap(), beats);
+ assert_eq!(anlz::song_structure(ext).unwrap(), Some(phrases));
+ assert_eq!(Some(track(&root, &t.title).bpm), analysis.tempo());
+ if with_wave {
+ let detail: Vec = anlz::waveform_from_bands(b"PWV5", &bands).unwrap().unwrap();
+ assert_eq!(anlz::waveform_entries(ext, b"PWV5").unwrap(), Some(detail));
+ assert_eq!(anlz::preview(dat).unwrap().map(|p| p.columns.len()), Some(400));
+ let staged = root.join(paths::STATE_DIR).join("waveforms").join(format!("{}.bands", Hash::of(&bytes)));
+ assert_eq!(std::fs::read(staged).unwrap(), bytes);
+ }
+ backup::restore(&mut d, None, false).unwrap();
+ assert_eq!(files(&root, &t), before);
+}
+
+/// The tempo a grid gives the databases is the one most of its beats have, the first of those
+/// alike.
+#[test]
+fn a_grids_tempo_is_most_of_its_beats() {
+ let tempo = |bpms: &[f64]| {
+ let beats = bpms.iter().enumerate().map(|(i, &bpm)| Beat { beat_number: 1, bpm, time_ms: i as u32 }).collect();
+ NewAnalysis { beats: Some(beats), ..Default::default() }.tempo()
+ };
+ assert_eq!(tempo(&[120.0, 128.0, 128.0]), Some(128.0));
+ assert_eq!(tempo(&[120.0, 128.0, 128.0, 120.0]), Some(120.0));
+ assert_eq!(tempo(&[127.996]), Some(128.0));
+ assert_eq!(NewAnalysis::default().tempo(), None);
+ let a = NewAnalysis { beats: Some(vec![]), phrases: None, waveform: Some(WaveSource::Staged(Hash::of(b"x"))) };
+ assert_eq!(a.describe("/Contents/a.mp3"), "set the beat grid and the waveform of /Contents/a.mp3");
+}
diff --git a/crates/locallibrary/tests/it/main.rs b/crates/locallibrary/tests/it/main.rs
index 29aa67f5..dbb579f8 100644
--- a/crates/locallibrary/tests/it/main.rs
+++ b/crates/locallibrary/tests/it/main.rs
@@ -20,6 +20,7 @@ mod schema;
mod session;
mod analysis;
+mod analysis_edit;
mod artwork;
mod check;
mod clean;
diff --git a/crates/locallibrary/tests/it/plugins.rs b/crates/locallibrary/tests/it/plugins.rs
index c62c84c3..c68e6482 100644
--- a/crates/locallibrary/tests/it/plugins.rs
+++ b/crates/locallibrary/tests/it/plugins.rs
@@ -1,6 +1,7 @@
//! The plugin host, running the test plugin (`src/bin/test-plugin`: loclib-plugin's echo
//! example, and what these tests need besides) as a child process, on a fixture drive.
+mod analysis;
mod blobs;
mod callbacks;
mod choose;
diff --git a/crates/locallibrary/tests/it/plugins/analysis.rs b/crates/locallibrary/tests/it/plugins/analysis.rs
new file mode 100644
index 00000000..6e7fac48
--- /dev/null
+++ b/crates/locallibrary/tests/it/plugins/analysis.rs
@@ -0,0 +1,343 @@
+//! Structured analysis (feature `analysis`): an analyzer's beat grid, phrases and waveform kept
+//! apart from its columns, refused when they're not what they say, and written to the tracks'
+//! analysis files as an edit plan only when asked (`plugins.apply_analysis`); and plugins'
+//! operations planning analysis edits, which takes `analysis:plan`.
+
+use locallibrary::analysis_edit::NewAnalysis;
+use locallibrary::anlz::{self, Bands, Beat, Mood, Phrase, PhraseKind, SongStructure};
+use locallibrary::edit::Edit;
+use locallibrary::fields::{Analyzer, FieldStore, OverlayStore};
+use locallibrary::paths;
+use locallibrary::plugin::{AnalysisPart, ApplyAnalysis};
+
+use super::*;
+
+/// The echo manifest with the analyzer `heard` answering a beat grid, phrases, a waveform and a
+/// tempo: what the test writes in each audio file.
+fn structured() -> String {
+ ECHO.replace(
+ "fields = { bytes = \"integer\" }\n",
+ "fields = { bytes = \"integer\" }\n\n[[contributes.analyzers]]\nname = \"heard\"\ntitle = \"Heard\"\n\
+ version = \"1\"\nfields = { grid = \"beats\", phrases = \"phrases\", wave = \"waveform\", tempo = \"number\" }\n",
+ )
+}
+
+fn heard() -> Analyzer {
+ Analyzer { plugin: "echo".into(), analyzer: "heard".into(), version: "1".into() }
+}
+
+fn grid(n: usize, from: u32) -> Vec {
+ (0..n).map(|i| Beat { beat_number: (i % 4) as u16 + 1, bpm: 120.0, time_ms: from + i as u32 * 500 }).collect()
+}
+
+fn phrases() -> SongStructure {
+ let phrase = |kind, start_beat, end_beat| Phrase { kind, start_beat, end_beat, fill_beat: None };
+ SongStructure {
+ mood: Mood::Mid,
+ phrases: vec![phrase(PhraseKind::Intro, 1, 33), phrase(PhraseKind::Chorus, 33, 65)],
+ }
+}
+
+/// What the analyzer answers for a track: its grid of `beats`, phrases, a waveform of `columns`
+/// at 75 a second in a blob, and a tempo.
+fn answer(beats: usize, columns: usize) -> Value {
+ let bands = Bands((0..columns).map(|i| [i as u8, 100, 200]).collect());
+ json!({
+ "grid": grid(beats, 25),
+ "phrases": phrases(),
+ "wave": { "blob": "wave.bands", "columns_per_sec": 75.0 },
+ "tempo": 120.0,
+ "blobs": { "wave.bands": bands.pack() },
+ })
+}
+
+impl Fixture {
+ fn hear_all(&self, answers: &[Value]) -> Vec {
+ let tracks = common::library(&self.drive).tracks;
+ for (t, answer) in tracks.iter().zip(answers) {
+ std::fs::write(self.audio(t), answer.to_string()).unwrap();
+ }
+ tracks
+ }
+
+ fn audio(&self, t: &locallibrary::Track) -> PathBuf {
+ paths::to_fs(&self.drive, &paths::key(&t.file_path))
+ }
+
+ fn apply_analysis(&self, parts: &[AnalysisPart], overwrite: bool) -> Result> {
+ let req = ApplyAnalysis {
+ plugin: "echo".into(),
+ analyzer: "heard".into(),
+ drive: "USB".into(),
+ parts: parts.to_vec(),
+ tracks: None,
+ overwrite,
+ };
+ self.plugins.plan_apply_analysis(&req, &self.store, &Ctx::silent())
+ }
+}
+
+use locallibrary::edit::EditPlan;
+use locallibrary::op::Planned;
+
+/// The grid, phrases and waveform go to the overlay store (the waveform put at 150 columns a
+/// second), the tempo to the columns; the structured fields are no columns.
+#[test]
+fn structured_fields_are_kept_apart_from_the_columns() {
+ let f = Fixture::new(|dir| vec![spec(dir, &structured(), &[])], Options::default());
+ let tracks = f.hear_all(&[answer(64, 300), json!({ "grid": null, "phrases": null, "wave": null, "tempo": 1 })]);
+ let analyzed = f.plugins.analyze("echo", "heard", "USB", None, &f.store, &Ctx::silent()).unwrap();
+ assert_eq!((analyzed.analyzed, analyzed.failed), (2, 0));
+ let overlays = OverlayStore::in_store(&f.store);
+ let kept = overlays.get(&f.audio(&tracks[0]), &heard()).unwrap();
+ assert_eq!(kept.beats, Some(grid(64, 25)));
+ assert_eq!(kept.phrases, Some(phrases()));
+ let wave = kept.waveform.unwrap();
+ assert_eq!((wave.0.len(), wave.0[0], wave.0[599]), (600, [0, 100, 200], [43, 100, 200]));
+ assert_eq!(overlays.get(&f.audio(&tracks[1]), &heard()), None, "nothing to report, nothing kept");
+ let fields = FieldStore::in_store(&f.store).get(&f.audio(&tracks[0]), &[heard()]);
+ assert_eq!(serde_json::to_value(&fields["echo"]["heard"]).unwrap(), json!({ "tempo": 120.0 }));
+ let columns: Vec = f.plugins.fields().into_keys().collect();
+ assert_eq!(columns, ["echo.bytes", "echo.tempo"]);
+ // Drawn from the first analyzer with each.
+ let shown = overlays.first(&f.audio(&tracks[0]), &[heard()]);
+ assert_eq!((shown.beats_from, shown.overlay.beats.map(|b| b.len())), (Some(heard()), Some(64)));
+}
+
+/// A structured field that isn't what its type says fails the track, saying why, and keeps
+/// nothing of it.
+#[test]
+fn structured_fields_that_arent_what_they_say_fail_the_track() {
+ let f = Fixture::new(|dir| vec![spec(dir, &structured(), &[])], Options::default());
+ let bad_beat = json!({ "grid": [{ "beat_number": 7, "bpm": 120.0, "time_ms": 0 }] });
+ let not_beats = json!({ "grid": [1, 2] });
+ let tracks = f.hear_all(&[bad_beat, not_beats]);
+ let analyzed = f.plugins.analyze("echo", "heard", "USB", None, &f.store, &Ctx::silent()).unwrap();
+ let errors: Vec<&str> = analyzed.failures.iter().map(|x| x.error.as_str()).collect();
+ assert_eq!(errors.len(), 2);
+ assert!(errors[0].ends_with("its grid: beat 1 is beat 7 of its bar: 1-4"), "{}", errors[0]);
+ assert!(errors[1].ends_with("its field grid isn't the beats heard declares"), "{}", errors[1]);
+ let overlays = OverlayStore::in_store(&f.store);
+ assert!(tracks.iter().all(|t| overlays.get(&f.audio(t), &heard()).is_none()));
+ for (wave, why) in [
+ (json!({ "blob": "gone.bands", "columns_per_sec": 75.0 }), "its wave: its file \"gone.bands\" isn't there"),
+ (
+ json!({ "blob": "two.bands", "columns_per_sec": 75.0 }),
+ "its wave isn't three bytes a column, at least one column",
+ ),
+ (json!({ "blob": "two.bands", "columns_per_sec": 0.0 }), "its field wave isn't the waveform heard declares"),
+ ] {
+ f.hear_all(&[json!({ "wave": wave, "blobs": { "two.bands": [1, 2] } })]);
+ // Kept already: analysed again by a version of its own.
+ let _ = std::fs::remove_dir_all(f.store.root().join("cache/fields"));
+ let analyzed =
+ f.plugins.analyze("echo", "heard", "USB", Some(&[tracks[0].id]), &f.store, &Ctx::silent()).unwrap();
+ let error = &analyzed.failures[0].error;
+ assert!(error.contains(why), "{error}");
+ }
+}
+
+/// What the analyzer kept is written to the tracks' analysis files as an edit plan: only over
+/// what they have when asked, a grid with its BPM, phrases only on the grid they count, and a
+/// track whose files can't take a waveform left out, saying why.
+#[test]
+fn the_analysis_is_written_as_an_edit() {
+ let f = Fixture::new(|dir| vec![spec(dir, &structured(), &[])], Options::default());
+ let tracks = f.hear_all(&[answer(64, 300), answer(64, 300)]);
+ // Only the first is analysed yet.
+ f.plugins.analyze("echo", "heard", "USB", Some(&[tracks[0].id]), &f.store, &Ctx::silent()).unwrap();
+ let kept = f.apply_analysis(&[AnalysisPart::Grid, AnalysisPart::Phrases], false).unwrap();
+ assert!(kept.noop, "the track has a grid and phrases: {:?}", kept.summary);
+ assert_eq!(
+ kept.warnings,
+ [
+ "1 track(s) aren't analysed by Heard yet",
+ "2 of what's asked the tracks have already, kept: overwrite to replace it"
+ ]
+ );
+ f.plugins.analyze("echo", "heard", "USB", None, &f.store, &Ctx::silent()).unwrap();
+
+ // Phrases alone count the analyzer's grid, not the tracks'.
+ let alone = f.apply_analysis(&[AnalysisPart::Phrases], true).unwrap();
+ assert!(alone.noop);
+ assert_eq!(alone.warnings, ["2 track(s)' phrases left out: they count the beats of Heard's grid, not the track's"]);
+ let waveform = f.apply_analysis(&[AnalysisPart::Waveform], true).unwrap();
+ assert!(waveform.noop);
+ assert!(waveform.warnings[0].starts_with("2 track(s) left out, e.g. "), "{:?}", waveform.warnings);
+ assert!(waveform.warnings[0].contains("(PWV4)"), "{:?}", waveform.warnings);
+
+ let planned = f.apply_analysis(&[AnalysisPart::Grid, AnalysisPart::Phrases], true).unwrap();
+ assert_eq!(&*planned.operation, "plugins.apply_analysis");
+ assert_eq!(planned.summary[0], "write the grid and phrases from Heard to 2 track(s)");
+ planned.apply(None, &Ctx::silent()).unwrap();
+ let device = locallibrary::Device::open_export(&f.drive).unwrap();
+ for t in common::library(&f.drive).tracks {
+ let a = anlz::load(&device, &t);
+ assert_eq!((a.beats, a.structure, t.bpm), (grid(64, 25), Some(phrases()), 120.0));
+ }
+ let journal = api::edits_list(&locallibrary::api::DriveRef { drive: common::path(&f.drive) }).unwrap();
+ assert_eq!(journal.len(), 1, "journaled");
+ assert!(f.apply_analysis(&[AnalysisPart::Grid, AnalysisPart::Phrases], true).unwrap().noop, "as it is now");
+}
+
+#[test]
+fn only_what_the_analyzer_works_out_is_written() {
+ let f = Fixture::new(|dir| vec![spec(dir, &structured(), &[])], Options::default());
+ let e = |parts: &[AnalysisPart]| f.apply_analysis(parts, false).unwrap_err().to_string();
+ assert_eq!(e(&[]), "say what to write: grid, phrases, waveform");
+ let req = ApplyAnalysis {
+ plugin: "echo".into(),
+ analyzer: "size".into(),
+ drive: "USB".into(),
+ parts: vec![AnalysisPart::Grid],
+ tracks: Some(vec![9999]),
+ overwrite: false,
+ };
+ let size = f.plugins.plan_apply_analysis(&req, &f.store, &Ctx::silent()).unwrap_err();
+ assert_eq!(size.to_string(), "File size works out no grid");
+ let req = ApplyAnalysis { analyzer: "heard".into(), ..req };
+ assert_eq!(f.plugins.plan_apply_analysis(&req, &f.store, &Ctx::silent()).unwrap_err().code(), "not_found");
+}
+
+/// A manifest declaring a structured field needs the `analysis` feature: one whose plugin says it
+/// hasn't it is refused as it starts.
+#[test]
+fn structured_fields_need_the_analysis_feature() {
+ let f = Fixture::new(|dir| vec![spec(dir, &structured(), &["--without", "analysis"])], Options::default());
+ let e = f.plugins.analyze("echo", "heard", "USB", None, &f.store, &Ctx::silent()).unwrap_err();
+ assert!(failure(e).contains("it says it doesn't have the analysis feature, which its manifest needs"));
+}
+
+/// An operation's analysis edit takes `analysis:plan`, and gives its waveform as a file in its
+/// scratch folder, which the host reads as it plans.
+#[test]
+fn an_operations_analysis_edit_takes_analysis_plan() {
+ let plan = |f: &Fixture, edit: Value| {
+ let params = json!({ "name": "x", "edits": [edit], "blobs": { "wave.bands": [1, 2, 3] } });
+ f.plugins.plan_op("echo", "playlist", params, f.context(), Some(&f.store), &Ctx::silent())?.plan()
+ };
+ let without = Fixture::echo();
+ let file = common::library(&without.drive).tracks[0].file_path.clone();
+ let wave = json!({ "op": "set_analysis", "track": file, "waveform": { "blob": "wave.bands" } });
+ let e = failure(plan(&without, wave.clone()).unwrap_err());
+ assert!(e.contains("its edit 0 needs the analysis:plan capability, which it doesn't have"), "{e}");
+ let with = Fixture::new(|dir| vec![spec(dir, &granting(&["analysis:plan"]), &[])], Options::default());
+ // Read from the file, then refused: rekordbox's colour overview can't be worked out from it.
+ let e = plan(&with, wave).unwrap_err().to_string();
+ assert!(e.contains("(PWV4)"), "{e}");
+ let given = json!({ "op": "set_analysis", "track": file, "waveform": { "base64": "AQID" } });
+ let e = failure(plan(&with, given).unwrap_err());
+ assert!(e.contains("its edit 0 gives a waveform otherwise than as a file in its scratch folder"), "{e}");
+ let beats = json!({ "op": "set_analysis", "track": file, "beats": grid(8, 0) });
+ let planned = plan(&with, beats).unwrap();
+ let analysis = NewAnalysis { beats: Some(grid(8, 0)), ..Default::default() };
+ assert_eq!(planned.plan.recorded[0], Edit::SetAnalysis { track: file, analysis });
+}
+
+/// What a track's drive lacks is drawn from what analyzers kept, each part from the first with
+/// it, and said: a grid where it has none, phrases where it has none on the grid they count,
+/// waveforms where it has none. What the drive has stays.
+#[hegel::test(test_cases = 100)]
+fn what_the_drive_lacks_is_drawn_from_the_overlay(tc: hegel::TestCase) {
+ use hegel::generators as gs;
+ use locallibrary::anlz::TrackAnalysis;
+ use locallibrary::fields::Overlay;
+ use locallibrary::fields::overlay::Shown;
+ let has = |tc: &hegel::TestCase| tc.draw(gs::booleans());
+ let drive_grid = grid(32, 10);
+ let drive = TrackAnalysis {
+ beats: if has(&tc) { drive_grid.clone() } else { Vec::new() },
+ structure: has(&tc).then(phrases),
+ preview: has(&tc).then(|| Bands(vec![[1, 1, 1]]).waveform(None)),
+ ..Default::default()
+ };
+ let own_grid = if has(&tc) { drive_grid.clone() } else { grid(32, 0) };
+ let (a, b) = (heard(), Analyzer { analyzer: "other".into(), ..heard() });
+ let overlay = Overlay {
+ beats: has(&tc).then(|| own_grid.clone()),
+ phrases: has(&tc).then(phrases),
+ waveform: has(&tc).then(|| Bands(vec![[9, 9, 9]; 300])),
+ };
+ let from = |given: bool| given.then(|| if has(&tc) { a.clone() } else { b.clone() });
+ let shown = Shown {
+ beats_from: from(overlay.beats.is_some()),
+ phrases_from: from(overlay.phrases.is_some()),
+ phrases_grid: overlay.beats.clone(),
+ waveform_from: from(overlay.waveform.is_some()),
+ overlay: overlay.clone(),
+ };
+ let mut filled = drive.clone();
+ let said = shown.clone().fill(&mut filled);
+
+ let grid_in = drive.beats.is_empty() && overlay.beats.is_some();
+ assert_eq!(filled.beats, if grid_in { own_grid.clone() } else { drive.beats.clone() });
+ let counts = overlay.beats.as_ref().is_none_or(|own| *own == filled.beats);
+ let phrases_in = drive.structure.is_none() && overlay.phrases.is_some() && counts;
+ assert_eq!(filled.structure.is_some(), drive.structure.is_some() || phrases_in);
+ let wave_in = overlay.waveform.is_some() && (drive.preview.is_none() || drive.detail.is_none());
+ match &drive.preview {
+ Some(p) => assert_eq!(filled.preview.as_ref(), Some(p)),
+ None => assert_eq!(filled.preview.as_ref().map(|p| p.columns.len()), wave_in.then_some(1200)),
+ }
+ assert_eq!(
+ filled.detail.as_ref().map(|d| (d.columns.len(), d.columns_per_sec)),
+ wave_in.then_some((300, Some(150.0)))
+ );
+ let parts: Vec<&str> = [(grid_in, "grid"), (phrases_in, "phrases"), (wave_in, "waveform")]
+ .into_iter()
+ .filter_map(|(on, part)| on.then_some(part))
+ .collect();
+ let listed = match parts.as_slice() {
+ [] => String::new(),
+ [one] => one.to_string(),
+ [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
+ };
+ match said {
+ None => assert!(parts.is_empty()),
+ Some(said) => {
+ assert!(said.starts_with(&format!("{listed} from echo.")) && said.ends_with(", not on the drive"), "{said}")
+ }
+ }
+}
+
+/// Any grid and waveform an analyzer answers are kept as given (the waveform put at 150 columns a
+/// second, as long), and planned onto the tracks as given, each with its BPM.
+#[hegel::test(test_cases = 6, suppress_health_check = [hegel::HealthCheck::TooSlow])]
+fn any_structured_answer_is_kept_and_planned_as_given(tc: hegel::TestCase) {
+ use hegel::generators as gs;
+ let f = Fixture::new(|dir| vec![spec(dir, &structured(), &[])], Options::default());
+ let n = tc.draw(gs::integers::().min_value(1).max_value(64));
+ let mut time = 0;
+ let beats: Vec = (0..n)
+ .map(|i| {
+ time += tc.draw(gs::integers::().min_value(1).max_value(900));
+ let bpm = f64::from(tc.draw(gs::integers::().min_value(6_000).max_value(18_000))) / 100.0;
+ Beat { beat_number: (i % 4) as u16 + 1, bpm, time_ms: time }
+ })
+ .collect();
+ let columns = tc.draw(gs::integers::().min_value(1).max_value(400));
+ let bytes: Vec = tc.draw(gs::vecs(gs::integers::()).min_size(3 * columns).max_size(3 * columns));
+ let rate = tc.draw(gs::sampled_from(vec![75.0, 150.0, 300.0]));
+ let answer = json!({
+ "grid": beats,
+ "wave": { "blob": "w.bands", "columns_per_sec": rate },
+ "blobs": { "w.bands": bytes },
+ });
+ let tracks = f.hear_all(&[answer.clone(), answer]);
+ let analyzed = f.plugins.analyze("echo", "heard", "USB", None, &f.store, &Ctx::silent()).unwrap();
+ assert_eq!(analyzed.analyzed, 2, "{:?}", analyzed.failures);
+ let kept = OverlayStore::in_store(&f.store).get(&f.audio(&tracks[0]), &heard()).unwrap();
+ assert_eq!(kept.beats.as_ref(), Some(&beats));
+ assert_eq!(kept.waveform, Some(anlz::at_detail_rate(&Bands::unpack(&bytes).unwrap(), rate)));
+ let planned = f.apply_analysis(&[AnalysisPart::Grid], true).unwrap();
+ let grids: Vec>> = planned
+ .plan
+ .recorded
+ .iter()
+ .filter_map(|e| match e {
+ Edit::SetAnalysis { analysis, .. } => Some(analysis.beats.as_ref()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(grids, [Some(&beats), Some(&beats)]);
+}
diff --git a/crates/loclib-plugin/README.md b/crates/loclib-plugin/README.md
index 1e39dbfa..1c71bb2c 100644
--- a/crates/loclib-plugin/README.md
+++ b/crates/loclib-plugin/README.md
@@ -155,6 +155,18 @@ must be JSON Schemas for an object that compile.
Each family of hooks is a feature of its own, with the methods the host calls for it and what a
manifest contributes for it. A plugin has a family's feature once it implements one of its hooks.
+### analysis
+
+No method of its own: an analyzer's fields may also be a beat grid (`beats`: `[{beat_number,
+bpm, time_ms}]`), phrases (`phrases`: `{mood, phrases: [{kind, start_beat, end_beat,
+fill_beat?}]}`) or a waveform (`waveform`: `{blob, columns_per_sec}`, the file holding three bytes
+a column, its lows, mids and highs, 0-255, `Bands` packed), one of each at most, or `null`.
+`FieldValue::beats`, `FieldValue::phrases` and `FieldValue::waveform` make them and `Output` reads
+them. Every plugin the SDK serves has the feature, and a host without it refuses a manifest
+declaring such a field. They're not columns: the host keeps them apart, draws them where a drive
+has none and writes them into analysis files only when the user asks. An operation's
+`set_analysis` edit needs `analysis:plan`.
+
### analyzers
`track/analyze {analyzer, track: {id, file_path, audio_path, analyze_path?}, context}` ->
@@ -163,7 +175,7 @@ worked out with nothing to show. A field left out wasn't worked out.
- `[[contributes.analyzers]]`: `name`, `title`, `version` (its own: locallibrary keeps its
values by it, so bump it when they'd change), `fields` (each `number`, `integer`, `string`,
- `boolean` or `string[]`) and `roles`. Fields become columns, so no two analyzers have one of a
+ `boolean` or `string[]`, or one of [analysis](#analysis)'s) and `roles`. Fields become columns, so no two analyzers have one of a
name.
- `roles` says which of an analyzer's fields locallibrary reads as what (`Role`), a role to a
field: `key` (a Camelot code, `8A`: a string), `bpm`, `key_confidence` and `bpm_confidence`
@@ -291,7 +303,7 @@ let (host, incoming) = Peer::new(from_plugin, to_plugin);
let hello = json!({ "protocol": "0.2.0", "host": { "name": "locallibrary", "version": "0.1.0" } });
let reply: Value = host.request(method::HELLO, &hello)?;
-assert_eq!(reply["features"], json!(["commands", "blobs", "net", "choose"]));
+assert_eq!(reply["features"], json!(["analysis", "commands", "blobs", "net", "choose"]));
let run = json!({ "command": "count", "context": { "drive": "USB" } });
let counting = host.send_request(method::RUN_COMMAND, &run)?;
let Ok(Incoming::Request(callback)) = incoming.recv() else { panic!("a callback") };
diff --git a/crates/loclib-plugin/src/features.rs b/crates/loclib-plugin/src/features.rs
index 96f9fb5a..2bab3176 100644
--- a/crates/loclib-plugin/src/features.rs
+++ b/crates/loclib-plugin/src/features.rs
@@ -23,6 +23,7 @@ pub struct Feature {
/// Every feature this side knows, in the order `hello` lists them (a family's placed as
/// `crate::hooks` says).
pub const FEATURES: &[Feature] = &[
+ hooks::analysis::FEATURE,
hooks::commands::FEATURE,
hooks::operations::FEATURE,
hooks::analyzers::FEATURE,
diff --git a/crates/loclib-plugin/src/hooks.rs b/crates/loclib-plugin/src/hooks.rs
index 0db84a92..0d7919a4 100644
--- a/crates/loclib-plugin/src/hooks.rs
+++ b/crates/loclib-plugin/src/hooks.rs
@@ -10,6 +10,7 @@
//! sidecar's contract: there a family's line goes right after the line of the family before it
//! alphabetically (first, when there's none). Families added side by side then add separate lines.
+pub(crate) mod analysis;
pub(crate) mod analyzers;
pub(crate) mod commands;
pub(crate) mod exporters;
diff --git a/crates/loclib-plugin/src/hooks/analysis.rs b/crates/loclib-plugin/src/hooks/analysis.rs
new file mode 100644
index 00000000..0db0dc84
--- /dev/null
+++ b/crates/loclib-plugin/src/hooks/analysis.rs
@@ -0,0 +1,94 @@
+//! Feature `analysis`: an analyzer's structured outputs, fields of type `beats` (a beat grid),
+//! `phrases` (a song structure) and `waveform` (three bands, in a blob), answered with its other
+//! fields from `track/analyze`. The host keeps them apart from the columns, draws them where a
+//! track's drive has none, and writes them to the tracks' analysis files only when asked.
+//!
+//! No method of its own: every plugin the SDK serves has it, and a host without it refuses a
+//! manifest declaring such a field.
+
+use loclib_model::analysis::{Beat, SongStructure};
+use serde::{Deserialize, Serialize};
+
+use crate::manifest::invalid;
+use crate::{Feature, FieldType, FieldValue, Manifest, ManifestError};
+
+pub(crate) const FEATURE: Feature = Feature {
+ name: "analysis",
+ methods: &[],
+ sends: &[],
+ contributed: |m| m.contributes.analyzers.iter().any(|a| a.fields.values().any(|t| t.is_structured())),
+};
+
+/// A `waveform` field's value: a file in the request's scratch folder holding three bytes a
+/// column, its lows, mids and highs, 0-255 ([`loclib_model::analysis::Bands`] packed), and how
+/// many columns a second of audio it has.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct WaveformBlob {
+ pub blob: String,
+ pub columns_per_sec: f64,
+}
+
+/// A structured field's value, as its type says.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Output {
+ Beats(Vec),
+ Phrases(SongStructure),
+ Waveform(WaveformBlob),
+}
+
+impl Output {
+ /// `value` as a field of type `kind` holds it (none for null: nothing to report), or why it
+ /// isn't one.
+ pub fn of(kind: FieldType, value: &FieldValue) -> Result, String> {
+ if *value == FieldValue::Null {
+ return Ok(None);
+ }
+ let json = serde_json::to_value(value).map_err(|e| e.to_string())?;
+ let parsed = match kind {
+ FieldType::Beats => serde_json::from_value(json).map(Output::Beats),
+ FieldType::Phrases => serde_json::from_value(json).map(Output::Phrases),
+ FieldType::Waveform => serde_json::from_value(json).map(Output::Waveform),
+ _ => return Err(format!("{kind:?} isn't a structured field's type")),
+ };
+ let out = parsed.map_err(|e| e.to_string())?;
+ match &out {
+ Output::Waveform(w) if !(w.columns_per_sec.is_finite() && w.columns_per_sec > 0.0) => {
+ Err(format!("a waveform's columns a second, {}, aren't more than 0", w.columns_per_sec))
+ }
+ _ => Ok(Some(out)),
+ }
+ }
+}
+
+impl FieldValue {
+ /// A `beats` field's value.
+ pub fn beats(beats: &[Beat]) -> FieldValue {
+ FieldValue::Structured(serde_json::to_value(beats).expect("beats are JSON"))
+ }
+
+ /// A `phrases` field's value.
+ pub fn phrases(phrases: &SongStructure) -> FieldValue {
+ FieldValue::Structured(serde_json::to_value(phrases).expect("phrases are JSON"))
+ }
+
+ /// A `waveform` field's value: the file `blob` names in the scratch folder.
+ pub fn waveform(blob: impl Into, columns_per_sec: f64) -> FieldValue {
+ let w = WaveformBlob { blob: blob.into(), columns_per_sec };
+ FieldValue::Structured(serde_json::to_value(w).expect("a waveform's blob is JSON"))
+ }
+}
+
+/// An analyzer has at most one structured field of each type.
+pub(crate) fn validate(m: &Manifest) -> Result<(), ManifestError> {
+ for (i, a) in m.contributes.analyzers.iter().enumerate() {
+ for kind in [FieldType::Beats, FieldType::Phrases, FieldType::Waveform] {
+ let mut named = a.fields.iter().filter(|(_, t)| **t == kind);
+ if let (Some(_), Some((second, _))) = (named.next(), named.next()) {
+ let kind = serde_json::to_value(kind).unwrap_or_default();
+ let message = format!("another field is its {} already", kind.as_str().unwrap_or("?"));
+ return Err(invalid(format!("contributes.analyzers[{i}].fields.{second}"), message));
+ }
+ }
+ }
+ Ok(())
+}
diff --git a/crates/loclib-plugin/src/hooks/analyzers.rs b/crates/loclib-plugin/src/hooks/analyzers.rs
index 3cce9491..d585ccdb 100644
--- a/crates/loclib-plugin/src/hooks/analyzers.rs
+++ b/crates/loclib-plugin/src/hooks/analyzers.rs
@@ -161,11 +161,23 @@ pub enum FieldType {
Boolean,
#[serde(rename = "string[]")]
Strings,
+ /// A beat grid (feature `analysis`, see [`crate::Output`]).
+ #[serde(rename = "beats")]
+ Beats,
+ /// A song structure: the mood and the phrases (feature `analysis`).
+ #[serde(rename = "phrases")]
+ Phrases,
+ /// A waveform in three bands, in a blob (feature `analysis`).
+ #[serde(rename = "waveform")]
+ Waveform,
}
impl FieldType {
/// Whether `value` is one of these (an integer is a number too), or null.
pub fn accepts(self, value: &FieldValue) -> bool {
+ if self.is_structured() {
+ return crate::Output::of(self, value).is_ok();
+ }
matches!(
(self, value),
(_, FieldValue::Null)
@@ -176,6 +188,11 @@ impl FieldType {
| (FieldType::Strings, FieldValue::Strings(_))
)
}
+
+ /// A beat grid, phrases or a waveform: kept apart from the columns (see [`crate::Output`]).
+ pub fn is_structured(self) -> bool {
+ matches!(self, FieldType::Beats | FieldType::Phrases | FieldType::Waveform)
+ }
}
/// A field's value, as an analyzer answers it and the host keeps it: of the type the manifest
@@ -190,6 +207,11 @@ pub enum FieldValue {
String(String),
Strings(Vec),
Null,
+ /// A beat grid, phrases or a waveform (feature `analysis`), as JSON: what [`crate::Output`]
+ /// reads. Made with [`FieldValue::beats`], [`FieldValue::phrases`] and
+ /// [`FieldValue::waveform`]; never a column.
+ #[schemars(skip)]
+ Structured(serde_json::Value),
}
/// An analyzer's version, which names a folder: letters, digits, `.`, `_`, `+` and `-`, not
diff --git a/crates/loclib-plugin/src/lib.rs b/crates/loclib-plugin/src/lib.rs
index 47d34d63..aee07f53 100644
--- a/crates/loclib-plugin/src/lib.rs
+++ b/crates/loclib-plugin/src/lib.rs
@@ -19,6 +19,7 @@ mod view;
pub use choice::{Choice, ChoiceItem};
pub use error::{ErrorData, RpcError, params};
pub use features::{FEATURES, Feature};
+pub use hooks::analysis::{Output, WaveformBlob};
pub use hooks::analyzers::{Analysis, AnalyzeTrack, Analyzer, FieldType, FieldValue, Role, Track};
pub use hooks::commands::{Command, Mode, RunCommand};
pub use hooks::exporters::{ExportSource, Exported, ExportedFile, Exporter, WriteExport};
diff --git a/crates/loclib-plugin/src/manifest.rs b/crates/loclib-plugin/src/manifest.rs
index cfc40845..5fcf74f1 100644
--- a/crates/loclib-plugin/src/manifest.rs
+++ b/crates/loclib-plugin/src/manifest.rs
@@ -225,6 +225,8 @@ pub enum Capability {
TagsPlan,
CuesPlan,
ArtworkPlan,
+ /// Plan writing beat grids, phrases and waveforms into tracks' analysis files.
+ AnalysisPlan,
/// `net:`: it talks to this server through the host (`net/fetch`), or with
/// `net:*.` to any server below that domain. Shown to the user; a native plugin can't
/// be held to it.
@@ -233,7 +235,7 @@ pub enum Capability {
impl Capability {
/// Every capability but `net:`.
- pub const NAMED: [Capability; 16] = [
+ pub const NAMED: [Capability; 17] = [
Capability::LibraryRead,
Capability::AnalysisRead,
Capability::ArtworkRead,
@@ -250,6 +252,7 @@ impl Capability {
Capability::TagsPlan,
Capability::CuesPlan,
Capability::ArtworkPlan,
+ Capability::AnalysisPlan,
];
/// Its name, unless it's `net:`.
@@ -271,6 +274,7 @@ impl Capability {
Capability::TagsPlan => "tags:plan",
Capability::CuesPlan => "cues:plan",
Capability::ArtworkPlan => "artwork:plan",
+ Capability::AnalysisPlan => "analysis:plan",
Capability::Net(_) => return None,
})
}
@@ -566,6 +570,7 @@ impl Manifest {
return Err(invalid(at, format!("{cli:?} is used twice")));
}
}
+ hooks::analysis::validate(self)?;
hooks::commands::validate(self)?;
hooks::operations::validate(self)?;
hooks::analyzers::validate(self)?;
diff --git a/crates/loclib-plugin/src/manifest/tests.rs b/crates/loclib-plugin/src/manifest/tests.rs
index e514c07e..5b9adba8 100644
--- a/crates/loclib-plugin/src/manifest/tests.rs
+++ b/crates/loclib-plugin/src/manifest/tests.rs
@@ -869,8 +869,16 @@ fn manifest(tc: &TestCase, first_party: bool) -> Manifest {
// Each analyzer a field of its own at least, and the rest shared out.
let names = drawn_names(tc, 3);
let n = names.len();
- let field_types =
- vec![FieldType::Number, FieldType::Integer, FieldType::String, FieldType::Boolean, FieldType::Strings];
+ let field_types = vec![
+ FieldType::Number,
+ FieldType::Integer,
+ FieldType::String,
+ FieldType::Boolean,
+ FieldType::Strings,
+ FieldType::Beats,
+ FieldType::Phrases,
+ FieldType::Waveform,
+ ];
let fields = tc.draw(gs::vecs(gs::from_regex(DRAWN_NAME)).min_size(n).max_size(n * 3).unique(true));
let mut fields = fields.into_iter();
let mut analyzers: Vec = names
@@ -886,7 +894,12 @@ fn manifest(tc: &TestCase, first_party: bool) -> Manifest {
.collect();
for field in fields {
let i = tc.draw(gs::integers::().max_value(n - 1));
- analyzers[i].fields.insert(field, tc.draw(gs::sampled_from(field_types.clone())));
+ let mut kind = tc.draw(gs::sampled_from(field_types.clone()));
+ // A structured field of each type at most.
+ if kind.is_structured() && analyzers[i].fields.values().any(|t| *t == kind) {
+ kind = FieldType::String;
+ }
+ analyzers[i].fields.insert(field, kind);
}
// Some roles for each, each from one of its fields of the role's type.
for a in &mut analyzers {
@@ -1122,6 +1135,7 @@ fn each_kind_of_mistake_is_refused_saying_where(tc: TestCase) {
"no fields",
"field name",
"field twice",
+ "structured twice",
"role field",
"role type",
"file or folder",
@@ -1382,6 +1396,19 @@ fn each_kind_of_mistake_is_refused_saying_where(tc: TestCase) {
}
}
}
+ "structured twice" => {
+ add(c, "analyzers", 1);
+ let i = index(&tc, c.analyzers.len());
+ let kind = tc.draw(gs::sampled_from(vec![FieldType::Beats, FieldType::Phrases, FieldType::Waveform]));
+ let fields = &mut c.analyzers[i].fields;
+ // Longer than any drawn name, so none of the analyzers' fields.
+ fields.insert("added-structured-1".into(), kind);
+ fields.insert("added-structured-2".into(), kind);
+ let second = fields.iter().filter(|(_, t)| **t == kind).nth(1).map(|(f, _)| f.clone()).unwrap();
+ let named = serde_json::to_value(kind).unwrap();
+ let said = format!("another field is its {} already", named.as_str().unwrap());
+ (format!("contributes.analyzers[{i}].fields.{second}: {said}"), false)
+ }
"role field" | "role type" => {
add(c, "analyzers", 1);
let i = index(&tc, c.analyzers.len());
@@ -1640,12 +1667,13 @@ fn without_a_schema_params_are_any_object(tc: TestCase) {
/// One of each kind of field value.
#[hegel::test_helper]
fn field_value(tc: &TestCase) -> FieldValue {
- match tc.draw(gs::integers::().max_value(5)) {
+ match tc.draw(gs::integers::().max_value(6)) {
0 => FieldValue::Integer(tc.draw(gs::integers())),
1 => FieldValue::Number(tc.draw(gs::floats::().allow_nan(false).allow_infinity(false))),
2 => FieldValue::Boolean(tc.draw(gs::booleans())),
3 => FieldValue::String(tc.draw(gs::text())),
4 => FieldValue::Strings(tc.draw(gs::vecs(gs::text()).max_size(3))),
+ 5 => FieldValue::Structured(json!({ "of": tc.draw(gs::integers::()) })),
_ => FieldValue::Null,
}
}
@@ -1664,6 +1692,8 @@ fn a_value_fits_its_field_type_alone(tc: TestCase) {
FieldValue::String(_) => Some(FieldType::String),
FieldValue::Strings(_) => Some(FieldType::Strings),
FieldValue::Null => None,
+ // What none of these hold.
+ FieldValue::Structured(_) => Some(FieldType::Beats),
};
for kind in [FieldType::Number, FieldType::Integer, FieldType::String, FieldType::Boolean, FieldType::Strings] {
let fits = own.is_none_or(|own| kind == own || (kind, own) == (FieldType::Number, FieldType::Integer));
@@ -1671,6 +1701,56 @@ fn a_value_fits_its_field_type_alone(tc: TestCase) {
}
}
+/// A beat grid, phrases or a waveform made for a field fits that field's type alone (and null
+/// fits each, as nothing to report; an empty grid is an empty list of strings too), reads back
+/// from JSON as the same, and is what [`crate::Output`] reads; a waveform needs columns a second.
+#[hegel::test(test_cases = 100)]
+fn structured_values_fit_their_type_alone(tc: TestCase) {
+ use loclib_model::analysis::{Beat, Mood, Phrase, PhraseKind, SongStructure};
+ let n = tc.draw(gs::integers::().max_value(4));
+ let beats: Vec =
+ (0..n).map(|i| Beat { beat_number: tc.draw(gs::integers::()), bpm: 120.5, time_ms: i as u32 }).collect();
+ let phrases = SongStructure {
+ mood: Mood::Low,
+ phrases: vec![Phrase { kind: PhraseKind::Verse(2), start_beat: 1, end_beat: 9, fill_beat: Some(5) }],
+ };
+ let rate = tc.draw(gs::floats::().allow_nan(false).allow_infinity(false));
+ let given = [
+ (FieldType::Beats, FieldValue::beats(&beats), Some(crate::Output::Beats(beats))),
+ (FieldType::Phrases, FieldValue::phrases(&phrases), Some(crate::Output::Phrases(phrases))),
+ (
+ FieldType::Waveform,
+ FieldValue::waveform("w.bands", rate),
+ (rate > 0.0).then(|| {
+ crate::Output::Waveform(crate::WaveformBlob { blob: "w.bands".into(), columns_per_sec: rate })
+ }),
+ ),
+ ];
+ let all = [
+ FieldType::Number,
+ FieldType::Integer,
+ FieldType::String,
+ FieldType::Boolean,
+ FieldType::Strings,
+ FieldType::Beats,
+ FieldType::Phrases,
+ FieldType::Waveform,
+ ];
+ for (own, value, output) in given {
+ let json = serde_json::to_string(&value).unwrap();
+ let back: FieldValue = serde_json::from_str(&json).unwrap();
+ assert_eq!(crate::Output::of(own, &back), crate::Output::of(own, &value), "{json}");
+ assert_eq!(crate::Output::of(own, &value).ok().flatten(), output);
+ for kind in all {
+ let fits = (kind == own && output.is_some()) || (kind == FieldType::Strings && json == "[]");
+ assert_eq!(kind.accepts(&back), fits, "{kind:?} {back:?}");
+ assert!(kind.accepts(&FieldValue::Null));
+ }
+ assert_eq!(crate::Output::of(own, &FieldValue::Null), Ok(None));
+ }
+ assert!(crate::Output::of(FieldType::Number, &FieldValue::Integer(1)).is_err());
+}
+
/// A setting takes a value of its type (an enum, one of its choices; a secret, text that isn't
/// empty) and nothing else.
#[hegel::test(test_cases = 100)]
@@ -1716,6 +1796,7 @@ fn capabilities_read_back_from_their_names_and_nothing_else(tc: TestCase) {
"tags:plan",
"cues:plan",
"artwork:plan",
+ "analysis:plan",
];
let s = match tc.draw(gs::integers::().max_value(3)) {
0 => tc.draw(gs::sampled_from(named.to_vec())).to_string(),
diff --git a/crates/loclib-plugin/src/sdk/tests.rs b/crates/loclib-plugin/src/sdk/tests.rs
index 71b620d5..08b25d6a 100644
--- a/crates/loclib-plugin/src/sdk/tests.rs
+++ b/crates/loclib-plugin/src/sdk/tests.rs
@@ -96,7 +96,8 @@ fn hello_answers_who_the_plugin_is_and_ignores_unknown_features() {
let reply: HelloReply = s.host.request(method::HELLO, &hello("0.2.7", &["telepathy"])).unwrap();
let plugin = PluginInfo { id: "echo".into(), version: "0.1.0".into() };
// And the SDK's own.
- let features = ["commands", "operations", "analyzers", "exporters", "providers", "blobs", "net", "choose"];
+ let features =
+ ["analysis", "commands", "operations", "analyzers", "exporters", "providers", "blobs", "net", "choose"];
let features = features.map(String::from).to_vec();
assert_eq!(reply, HelloReply { protocol: PROTOCOL_VERSION.into(), plugin, features });
let again = s.call(method::HELLO, hello(PROTOCOL_VERSION, &[])).unwrap_err();
@@ -305,7 +306,7 @@ fn hooks_run_only_for_what_the_manifest_declares() {
fn a_hook_the_plugin_doesnt_implement_isnt_found() {
let s = start(Hooks::new().on(echo::analyze_track));
let reply: HelloReply = s.host.request(method::HELLO, &hello(PROTOCOL_VERSION, &[])).unwrap();
- assert_eq!(reply.features, ["analyzers", "blobs", "net", "choose"]);
+ assert_eq!(reply.features, ["analysis", "analyzers", "blobs", "net", "choose"]);
let track = json!({ "id": 1, "file_path": "/a.mp3", "audio_path": "/a.mp3" });
for (method, params) in [
(method::RUN_COMMAND, run("echo", json!({}), None)),
@@ -320,7 +321,7 @@ fn a_hook_the_plugin_doesnt_implement_isnt_found() {
assert_eq!(e.kind(), Some("io"), "the one it has runs");
let none = start(Hooks::new());
let reply: HelloReply = none.host.request(method::HELLO, &hello(PROTOCOL_VERSION, &[])).unwrap();
- assert_eq!(reply.features, ["blobs", "net", "choose"], "only the SDK's own");
+ assert_eq!(reply.features, ["analysis", "blobs", "net", "choose"], "only the SDK's own");
none.stop();
s.stop();
}
@@ -475,7 +476,8 @@ fn hello_comes_first_and_then_the_plugin_describes_its_manifest(tc: TestCase) {
vec!["describe", "command/run", "track/analyze", "export/write", "op/plan", "tracks.query", "shutdown"];
let plugin = PluginInfo { id: manifest.id.clone(), version: manifest.version.clone() };
// And the SDK's own.
- let features = ["commands", "operations", "analyzers", "exporters", "providers", "blobs", "net", "choose"];
+ let features =
+ ["analysis", "commands", "operations", "analyzers", "exporters", "providers", "blobs", "net", "choose"];
let features = features.map(String::from).to_vec();
let greeting = HelloReply { protocol: PROTOCOL_VERSION.into(), plugin, features };
let mut greeted = false;
From 22c7c8b74f3b83000d9980c7d72c85be0059fc12 Mon Sep 17 00:00:00 2001
From: botandbang <337765928+botandbang@users.noreply.github.com>
Date: Wed, 21 Oct 2026 00:39:56 -0400
Subject: [PATCH 3/5] anlz: a plugin's waveform over a day long fails its track
instead of filling memory, and refused analysis edits, overlays and damaged
files have properties
---
crates/locallibrary/src/analysis_edit.rs | 12 +-
crates/locallibrary/src/anlz/waves.rs | 15 +-
crates/locallibrary/src/plugin/analyzers.rs | 4 +-
crates/locallibrary/tests/it/analysis_edit.rs | 110 ++++++++++++++
.../locallibrary/tests/it/plugins/analysis.rs | 139 +++++++++++++++++-
.../locallibrary/tests/it/props_anlz_write.rs | 99 +++++++++++--
crates/loclib-plugin/README.md | 6 +-
7 files changed, 361 insertions(+), 24 deletions(-)
diff --git a/crates/locallibrary/src/analysis_edit.rs b/crates/locallibrary/src/analysis_edit.rs
index fbc35917..6e1a4878 100644
--- a/crates/locallibrary/src/analysis_edit.rs
+++ b/crates/locallibrary/src/analysis_edit.rs
@@ -324,9 +324,9 @@ pub(crate) fn replay(
};
let made = track(&library, file).and_then(|t| {
let bands = hash.map(|h| staged_bands(device, &h, staged)).transpose()?;
- rewrite(t, &read(device, t)?, a, bands.as_ref())
+ Ok((rewrite(t, &read(device, t)?, a, bands.as_ref())?, bands))
});
- let (changed, warnings) = match made {
+ let ((changed, warnings), bands) = match made {
Ok(made) => made,
Err(e) => return out.skipped(strict, &described, e),
};
@@ -334,10 +334,10 @@ pub(crate) fn replay(
if changed.is_empty() {
return Ok(out);
}
- // Not on the drive yet, it was read from `staged`.
- let fresh = hash.map(|h| (WAVEFORMS.path(device, &h), h)).filter(|(p, _)| !p.is_file());
- if let Some((path, hash)) = &fresh {
- fsutil::write_atomic(path, &staged[hash])?;
+ // Not on the drive yet (it was read from `staged`), it's staged as it was read.
+ let fresh = hash.zip(bands).map(|(h, b)| (WAVEFORMS.path(device, &h), b)).filter(|(p, _)| !p.is_file());
+ if let Some((path, bands)) = &fresh {
+ fsutil::write_atomic(path, &bands.pack())?;
}
let changes =
Changes { files: changed.into_iter().map(|(k, b)| NewFile::bytes(k, b)).collect(), ..Default::default() };
diff --git a/crates/locallibrary/src/anlz/waves.rs b/crates/locallibrary/src/anlz/waves.rs
index e4aa730c..b53f0071 100644
--- a/crates/locallibrary/src/anlz/waves.rs
+++ b/crates/locallibrary/src/anlz/waves.rs
@@ -92,10 +92,17 @@ pub fn write_waveform(bytes: &[u8], tag: &[u8; 4], columns: &[u8]) -> Result Bands {
- let n = (bands.0.len() as f64 * DETAIL_RATE / rate).round() as usize;
- bands.pooled(n.max(1))
+/// The most columns [`at_detail_rate`] makes, a day's: however slow a plugin says its waveform
+/// is, pooling it never allocates more.
+const MOST_DETAIL_COLUMNS: f64 = 24.0 * 3600.0 * DETAIL_RATE;
+
+/// `bands` (at `rate` columns a second) at [`DETAIL_RATE`], as long, unless that's over a day.
+pub fn at_detail_rate(bands: &Bands, rate: f64) -> Result {
+ let n = (bands.0.len() as f64 * DETAIL_RATE / rate).round();
+ if n.is_nan() || n > MOST_DETAIL_COLUMNS {
+ return Err(Error::invalid(format!("{} columns at {rate} a second run over a day", bands.0.len())));
+ }
+ Ok(bands.pooled((n as usize).max(1)))
}
/// The columns of waveform `tag` worked out from `bands` (the detail, at [`DETAIL_RATE`]; previews
diff --git a/crates/locallibrary/src/plugin/analyzers.rs b/crates/locallibrary/src/plugin/analyzers.rs
index 409eb88f..4a9baf34 100644
--- a/crates/locallibrary/src/plugin/analyzers.rs
+++ b/crates/locallibrary/src/plugin/analyzers.rs
@@ -199,7 +199,9 @@ impl Slot {
let bands = Bands::unpack(&bytes).filter(|b| !b.0.is_empty()).ok_or_else(|| {
self.misbehaved(format!("its {name} isn't three bytes a column, at least one column"))
})?;
- o.waveform = Some(anlz::at_detail_rate(&bands, w.columns_per_sec));
+ let bands = anlz::at_detail_rate(&bands, w.columns_per_sec)
+ .map_err(|e| self.misbehaved(format!("its {name}: {e}")))?;
+ o.waveform = Some(bands);
}
}
}
diff --git a/crates/locallibrary/tests/it/analysis_edit.rs b/crates/locallibrary/tests/it/analysis_edit.rs
index d7445740..da97d64d 100644
--- a/crates/locallibrary/tests/it/analysis_edit.rs
+++ b/crates/locallibrary/tests/it/analysis_edit.rs
@@ -359,3 +359,113 @@ fn a_grids_tempo_is_most_of_its_beats() {
let a = NewAnalysis { beats: Some(vec![]), phrases: None, waveform: Some(WaveSource::Staged(Hash::of(b"x"))) };
assert_eq!(a.describe("/Contents/a.mp3"), "set the beat grid and the waveform of /Contents/a.mp3");
}
+
+/// Each way an analysis edit can't be made is refused as it's planned, saying why, and leaves
+/// the drive as it was, no waveform staged: nothing to write, a grid or phrases the files can't
+/// hold, a beat past the end, phrases without an `.EXT`, no `.DAT`, no analysis files at all or
+/// ones outside rekordbox's folder, a waveform over rekordbox's colour overview, one that isn't
+/// bands, given as a plugin's blob or staged and gone, and a track that isn't on the drive.
+#[hegel::test(test_cases = 40, suppress_health_check = [HealthCheck::TooSlow])]
+fn analysis_edits_that_cant_be_made_change_nothing(tc: TestCase) {
+ let dir = tempfile::tempdir().unwrap();
+ let case = tc.draw(gs::integers::().max_value(14));
+ let root = match case {
+ 13 => common::bigger_drive(dir.path(), "USB"),
+ _ => common::demo_drive(dir.path(), "USB"),
+ };
+ let t = track(&root, "Demo Track 2");
+ let name = t.display_name();
+ let dat = paths::to_fs(&root, &paths::key(&t.analyze_path));
+ let beat = |n: u16, bpm: f64, ms: u32| Beat { beat_number: n, bpm, time_ms: ms };
+ let grid = |beats: Vec| NewAnalysis { beats: Some(beats), ..Default::default() };
+ let phrased = |mood, phrases: Vec<(PhraseKind, u16, u16)>| NewAnalysis {
+ phrases: Some(SongStructure {
+ mood,
+ phrases: phrases
+ .into_iter()
+ .map(|(kind, start_beat, end_beat)| Phrase { kind, start_beat, end_beat, fill_beat: None })
+ .collect(),
+ }),
+ ..Default::default()
+ };
+ let waved = |w: WaveSource| NewAnalysis { waveform: Some(w), ..Default::default() };
+ let intro = vec![(PhraseKind::Intro, 1, 9)];
+ let (edit, why) = match case {
+ 0 => (set(&t, NewAnalysis::default()), "say what to write: a beat grid, phrases or a waveform".to_string()),
+ 1 => {
+ let n = tc.draw(hegel::one_of!(gs::just(0u16), gs::integers::().min_value(5)));
+ (set(&t, grid(vec![beat(n, 120.0, 0)])), format!("beat 1 is beat {n} of its bar: 1-4"))
+ }
+ 2 => {
+ let bpm = tc.draw(hegel::one_of!(
+ gs::just(f64::NAN),
+ gs::floats::().max_value(0.004),
+ gs::floats::().min_value(655.36)
+ ));
+ (set(&t, grid(vec![beat(1, bpm, 0)])), format!("beat 1's tempo, {bpm} BPM, isn't 0.01-655.35"))
+ }
+ 3 => {
+ let ms = tc.draw(gs::integers::().max_value(10_000));
+ let back = tc.draw(gs::integers::().max_value(ms));
+ (
+ set(&t, grid(vec![beat(1, 120.0, ms), beat(2, 120.0, back)])),
+ "beat 2 isn't after the one before it".into(),
+ )
+ }
+ 4 => {
+ let past = (t.duration_secs + 1) * 1000 + tc.draw(gs::integers::().max_value(1_000_000));
+ (set(&t, grid(steady(1, 120.0, past))), format!("is past the end of {name}"))
+ }
+ 5 => {
+ let gap = tc.draw(gs::integers::().min_value(10).max_value(100));
+ let gapped = phrased(Mood::Mid, vec![(PhraseKind::Intro, 1, 9), (PhraseKind::Outro, gap, gap + 8)]);
+ (set(&t, gapped), "phrases follow one another from beat 1".into())
+ }
+ 6 => (
+ set(&t, phrased(Mood::High, vec![(PhraseKind::Bridge, 1, 9)])),
+ "a High track has no Bridge phrases".into(),
+ ),
+ 7 => {
+ std::fs::remove_file(dat.with_extension("EXT")).unwrap();
+ (set(&t, phrased(Mood::Low, intro)), format!("{name} has no .EXT analysis file to hold phrases"))
+ }
+ 8 => {
+ std::fs::remove_file(&dat).unwrap();
+ (set(&t, grid(steady(4, 120.0, 0))), format!("{name} has no .DAT analysis file"))
+ }
+ 9 => (set(&t, waved(wave(&bands(150)))), "(PWV4) that can't be worked out from another waveform".into()),
+ 10 => {
+ let mut bytes = tc.draw(gs::binary().max_size(40));
+ if !bytes.is_empty() && bytes.len().is_multiple_of(3) {
+ bytes.pop();
+ }
+ let why = "a waveform is three bytes a column (lows, mids, highs), at least one column";
+ match tc.draw(gs::booleans()) {
+ true => (set(&t, waved(WaveSource::Base64(BASE64_STANDARD.encode(bytes)))), why.into()),
+ false => (set(&t, waved(WaveSource::Base64("not base64!".into()))), "the waveform isn't base64".into()),
+ }
+ }
+ 11 => (
+ set(&t, waved(WaveSource::Blob("w.bands".into()))),
+ "only a plugin's plan gives a waveform as a blob".into(),
+ ),
+ 12 => (set(&t, waved(WaveSource::Staged(Hash::of(b"gone")))), "the staged waveform".into()),
+ 13 => (set(&t, grid(steady(4, 120.0, 0))), format!("refusing analysis path {:?} of {name}", t.analyze_path)),
+ _ => {
+ let audio = "/Contents/none/a.mp3";
+ common::add_track(
+ &root,
+ rekordbox_pdb::NewTrack::new("Unanalysed", audio),
+ &common::placeholder_audio(audio),
+ );
+ let t = track(&root, "Unanalysed");
+ let why = format!("{} has no analysis files (rekordbox hasn't analysed it)", t.display_name());
+ (set(&t, grid(steady(4, 120.0, 0))), why)
+ }
+ };
+ let before = common::drive_files(&root);
+ let err = plan(&root, &[edit]).unwrap_err().to_string();
+ assert!(err.contains(&why), "{err}");
+ assert!(common::changed_files(&before, &common::drive_files(&root)).is_empty());
+ assert!(!root.join(paths::STATE_DIR).join("waveforms").exists());
+}
diff --git a/crates/locallibrary/tests/it/plugins/analysis.rs b/crates/locallibrary/tests/it/plugins/analysis.rs
index 6e7fac48..b7fd7874 100644
--- a/crates/locallibrary/tests/it/plugins/analysis.rs
+++ b/crates/locallibrary/tests/it/plugins/analysis.rs
@@ -328,7 +328,7 @@ fn any_structured_answer_is_kept_and_planned_as_given(tc: hegel::TestCase) {
assert_eq!(analyzed.analyzed, 2, "{:?}", analyzed.failures);
let kept = OverlayStore::in_store(&f.store).get(&f.audio(&tracks[0]), &heard()).unwrap();
assert_eq!(kept.beats.as_ref(), Some(&beats));
- assert_eq!(kept.waveform, Some(anlz::at_detail_rate(&Bands::unpack(&bytes).unwrap(), rate)));
+ assert_eq!(kept.waveform, anlz::at_detail_rate(&Bands::unpack(&bytes).unwrap(), rate).ok());
let planned = f.apply_analysis(&[AnalysisPart::Grid], true).unwrap();
let grids: Vec>> = planned
.plan
@@ -341,3 +341,140 @@ fn any_structured_answer_is_kept_and_planned_as_given(tc: hegel::TestCase) {
.collect();
assert_eq!(grids, [Some(&beats), Some(&beats)]);
}
+
+/// What an analyzer kept reads back as it was put, for the audio file as it is now (changed, it
+/// has nothing kept), and is drawn from the first analyzer with each part. Names that aren't a
+/// plugin's, an analyzer's or a version are refused, an audio file that isn't there keeps
+/// nothing, and a stored overlay that's damaged reads as none.
+#[hegel::test(test_cases = 100)]
+fn overlays_read_back_as_kept_and_refuse_what_isnt_theirs(tc: hegel::TestCase) {
+ use hegel::generators as gs;
+ use locallibrary::fields::Overlay;
+ let dir = tempfile::tempdir().unwrap();
+ let store = Store::open_or_init(dir.path().join("store")).unwrap();
+ let overlays = OverlayStore::in_store(&store);
+ let audio = dir.path().join("a.mp3");
+ std::fs::write(&audio, b"audio").unwrap();
+ let drawn = || {
+ let columns = tc.draw(gs::binary().max_size(30));
+ Overlay {
+ beats: tc.draw(gs::booleans()).then(|| grid(tc.draw(gs::integers::().max_value(40)), 0)),
+ phrases: tc.draw(gs::booleans()).then(phrases),
+ waveform: tc
+ .draw(gs::booleans())
+ .then(|| Bands(columns.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect())),
+ }
+ };
+ let (first, second) = (drawn(), drawn());
+ let other = Analyzer { analyzer: "other".into(), ..heard() };
+ let stored = || {
+ walkdir::WalkDir::new(store.root().join("cache/overlay"))
+ .into_iter()
+ .flatten()
+ .filter(|e| e.file_type().is_file())
+ .count()
+ };
+ match tc.draw(gs::integers::().max_value(3)) {
+ 0 => {
+ let bad = tc.draw(gs::sampled_from(vec!["", ".hidden", "a/b", "a b", "..", "é"]));
+ let named = match tc.draw(gs::integers::().max_value(2)) {
+ 0 => Analyzer { plugin: bad.into(), ..heard() },
+ 1 => Analyzer { analyzer: bad.into(), ..heard() },
+ _ => Analyzer { version: bad.into(), ..heard() },
+ };
+ let e = overlays.put(&audio, &named, &first).unwrap_err().to_string();
+ assert_eq!(e, format!("{bad:?} isn't a plugin id, version or analyzer"));
+ assert_eq!(stored(), 0);
+ }
+ 1 => {
+ let gone = dir.path().join("gone.mp3");
+ assert_eq!(overlays.put(&gone, &heard(), &first).unwrap_err().code(), "not_found");
+ assert_eq!(overlays.get(&gone, &heard()), None);
+ assert_eq!(stored(), 0);
+ }
+ 2 => {
+ overlays.put(&audio, &heard(), &first).unwrap();
+ let file = walkdir::WalkDir::new(store.root().join("cache/overlay"))
+ .into_iter()
+ .flatten()
+ .find(|e| e.file_type().is_file())
+ .unwrap();
+ let bytes = std::fs::read(file.path()).unwrap();
+ let keep = tc.draw(gs::integers::().max_value(bytes.len() - 1));
+ std::fs::write(file.path(), &bytes[..keep]).unwrap();
+ assert_eq!(overlays.get(&audio, &heard()), None);
+ }
+ _ => {
+ overlays.put(&audio, &heard(), &first).unwrap();
+ overlays.put(&audio, &other, &second).unwrap();
+ assert_eq!(overlays.get(&audio, &heard()).as_ref(), Some(&first));
+ let shown = overlays.first(&audio, &[heard(), other.clone()]);
+ let pick = |mine: bool, theirs: bool| match (mine, theirs) {
+ (true, _) => Some(heard()),
+ (false, true) => Some(other.clone()),
+ _ => None,
+ };
+ assert_eq!(shown.beats_from, pick(first.beats.is_some(), second.beats.is_some()));
+ assert_eq!(shown.phrases_from, pick(first.phrases.is_some(), second.phrases.is_some()));
+ assert_eq!(shown.waveform_from, pick(first.waveform.is_some(), second.waveform.is_some()));
+ let beats = if first.beats.is_some() { &first.beats } else { &second.beats };
+ assert_eq!(&shown.overlay.beats, beats);
+ // A changed audio file has nothing kept.
+ std::fs::write(&audio, b"another take").unwrap();
+ assert_eq!(overlays.get(&audio, &heard()), None);
+ }
+ }
+}
+
+/// Writing what an analyzer kept is refused, before anything's planned, when it isn't asked for
+/// anything, when the analyzer doesn't work out what's asked, and for an analyzer, a plugin, a
+/// drive or a track that isn't there.
+#[hegel::test(test_cases = 12, suppress_health_check = [hegel::HealthCheck::TooSlow])]
+fn writing_an_analysis_is_refused_for_what_isnt_there(tc: hegel::TestCase) {
+ use hegel::generators as gs;
+ let f = Fixture::new(|dir| vec![spec(dir, &structured(), &[])], Options::default());
+ let parts = tc.draw(gs::vecs(gs::sampled_from(AnalysisPart::ALL.to_vec())).min_size(1).max_size(3));
+ let req = ApplyAnalysis {
+ plugin: "echo".into(),
+ analyzer: "heard".into(),
+ drive: "USB".into(),
+ parts: parts.clone(),
+ tracks: None,
+ overwrite: tc.draw(gs::booleans()),
+ };
+ let (req, code) = match tc.draw(gs::integers::().max_value(5)) {
+ 0 => (ApplyAnalysis { parts: Vec::new(), ..req }, "invalid"),
+ 1 => (ApplyAnalysis { analyzer: "size".into(), ..req }, "invalid"),
+ 2 => (ApplyAnalysis { analyzer: "unheard".into(), ..req }, "not_found"),
+ 3 => (ApplyAnalysis { plugin: "nobody".into(), ..req }, "not_found"),
+ 4 => (ApplyAnalysis { drive: "NOWHERE".into(), ..req }, "not_found"),
+ _ => {
+ let id = tc.draw(gs::integers::().min_value(10_000));
+ (ApplyAnalysis { tracks: Some(vec![id]), ..req }, "not_found")
+ }
+ };
+ let e = f.plugins.plan_apply_analysis(&req, &f.store, &Ctx::silent()).unwrap_err();
+ assert_eq!(e.code(), code, "{e}");
+ if req.analyzer == "size" {
+ assert_eq!(e.to_string(), format!("File size works out no {}", parts[0].name()));
+ }
+}
+
+/// A waveform an analyzer says is slower than a day's worth of columns fails the track, saying
+/// so, rather than being pooled out to fill memory, and keeps nothing of it.
+#[hegel::test(test_cases = 6, suppress_health_check = [hegel::HealthCheck::TooSlow])]
+fn waveforms_over_a_day_fail_the_track(tc: hegel::TestCase) {
+ use hegel::generators as gs;
+ let f = Fixture::new(|dir| vec![spec(dir, &structured(), &[])], Options::default());
+ let columns = tc.draw(gs::integers::().min_value(1).max_value(100));
+ let rate = tc.draw(gs::floats::().min_value(f64::MIN_POSITIVE).max_value(1e-5));
+ let answer = json!({
+ "wave": { "blob": "w.bands", "columns_per_sec": rate },
+ "blobs": { "w.bands": vec![7u8; 3 * columns] },
+ });
+ let tracks = f.hear_all(&[answer]);
+ let analyzed = f.plugins.analyze("echo", "heard", "USB", Some(&[tracks[0].id]), &f.store, &Ctx::silent()).unwrap();
+ let error = &analyzed.failures[0].error;
+ assert!(error.ends_with(&format!("its wave: {columns} columns at {rate} a second run over a day")), "{error}");
+ assert_eq!(OverlayStore::in_store(&f.store).get(&f.audio(&tracks[0]), &heard()), None);
+}
diff --git a/crates/locallibrary/tests/it/props_anlz_write.rs b/crates/locallibrary/tests/it/props_anlz_write.rs
index c179bcbc..0679c081 100644
--- a/crates/locallibrary/tests/it/props_anlz_write.rs
+++ b/crates/locallibrary/tests/it/props_anlz_write.rs
@@ -280,6 +280,16 @@ fn phrases_that_cant_be_stored_are_refused() {
assert_eq!(refused(Mood::Mid, vec![fill]), "phrase 1's fill-in starts on beat 0: beats count from 1");
}
+/// The bytes a column of waveform `tag` takes.
+fn column_size(tag: &[u8; 4]) -> usize {
+ match tag {
+ b"PWV4" => 6,
+ b"PWV5" => 2,
+ b"PWV6" | b"PWV7" => 3,
+ _ => 1,
+ }
+}
+
/// A waveform tag's stored columns: whole columns of its size.
#[hegel::test_helper]
fn columns(tc: &TestCase, size: usize) -> Vec {
@@ -292,12 +302,7 @@ fn columns(tc: &TestCase, size: usize) -> Vec {
#[hegel::test(test_cases = 200)]
fn waveforms_read_back_as_written(tc: TestCase) {
let tag = *tc.draw(gs::sampled_from(anlz::waveform_tags().collect::>()));
- let size = match &tag {
- b"PWV4" => 6,
- b"PWV5" => 2,
- b"PWV6" | b"PWV7" => 3,
- _ => 1,
- };
+ let size = column_size(&tag);
let file = base(&tc, tc.draw(gs::booleans()));
let cols = columns(&tc, size);
let out = anlz::write_waveform(&file, &tag, &cols).unwrap();
@@ -368,9 +373,85 @@ fn waveforms_are_worked_out_from_bands(tc: TestCase) {
#[test]
fn bands_go_to_the_detail_rate() {
let b = Bands(vec![[1, 2, 3]; 100]);
- assert_eq!(anlz::at_detail_rate(&b, 75.0).0.len(), 200);
- assert_eq!(anlz::at_detail_rate(&b, 300.0).0.len(), 50);
- assert_eq!(anlz::at_detail_rate(&Bands::default(), 150.0).0.len(), 1);
+ assert_eq!(anlz::at_detail_rate(&b, 75.0).unwrap().0.len(), 200);
+ assert_eq!(anlz::at_detail_rate(&b, 300.0).unwrap().0.len(), 50);
+ assert_eq!(anlz::at_detail_rate(&Bands::default(), 150.0).unwrap().0.len(), 1);
+}
+
+/// Bands at any rate are pooled to 150 columns a second, as long in time, up to a day of audio;
+/// slower ones, which would fill memory, are refused.
+#[hegel::test(test_cases = 200)]
+fn bands_over_a_day_are_refused(tc: TestCase) {
+ let n = tc.draw(gs::integers::().min_value(1).max_value(2_000));
+ let rate = tc.draw(gs::floats::().min_value(f64::MIN_POSITIVE).max_value(1e6));
+ let b = Bands(vec![[1, 2, 3]; n]);
+ let want = (n as f64 * 150.0 / rate).round();
+ match anlz::at_detail_rate(&b, rate) {
+ Ok(pooled) => {
+ assert!(want <= 24.0 * 3600.0 * 150.0);
+ assert_eq!(pooled.0.len(), (want as usize).max(1));
+ assert!(pooled.0.iter().all(|c| *c == [1, 2, 3]));
+ }
+ Err(e) => {
+ assert!(want > 24.0 * 3600.0 * 150.0);
+ assert_eq!(e.to_string(), format!("{n} columns at {rate} a second run over a day"));
+ }
+ }
+}
+
+/// A damaged analysis file (bytes changed, perhaps in a section's header, or cut short) is
+/// never a panic for a writer: each refuses it, or writes a file holding what it was given,
+/// whatever damage the other sections keep; nor for a reader.
+#[hegel::test(test_cases = 300)]
+fn writers_take_damaged_files_without_panicking(tc: TestCase) {
+ let ext = tc.draw(gs::booleans());
+ let mut file = base(&tc, ext);
+ let starts: Vec = {
+ let be = |at: usize| u32::from_be_bytes(file[at..at + 4].try_into().unwrap()) as usize;
+ let mut at = vec![0];
+ let mut pos = be(4);
+ while pos + 12 <= be(8) {
+ at.push(pos);
+ pos += be(pos + 8);
+ }
+ at
+ };
+ for _ in 0..tc.draw(gs::integers::().min_value(1).max_value(6)) {
+ let at = tc.draw(gs::sampled_from(starts.clone())) + tc.draw(gs::integers::().max_value(31));
+ if let Some(b) = file.get_mut(at) {
+ *b = tc.draw(gs::integers::());
+ }
+ }
+ if tc.draw(gs::booleans()) {
+ file.truncate(tc.draw(gs::integers::().max_value(file.len())));
+ }
+ let tag = *tc.draw(gs::sampled_from(anlz::waveform_tags().collect::>()));
+ let columns = vec![9; column_size(&tag) * 4];
+ let (beats, phrases) = (grid(&tc), structure(&tc));
+ let mut written = Vec::new();
+ if let Ok(out) = anlz::write_beat_grid(&file, &beats) {
+ assert_eq!(anlz::beat_grid(&out).unwrap(), beats);
+ written.push(out);
+ }
+ if let Ok(out) = anlz::write_song_structure(&file, Some(&phrases)) {
+ assert_eq!(anlz::song_structure(&out).unwrap(), Some(phrases));
+ written.push(out);
+ }
+ if let Ok(out) = anlz::write_song_structure(&file, None) {
+ assert_eq!(anlz::song_structure(&out).unwrap(), None);
+ written.push(out);
+ }
+ if let Ok(out) = anlz::without_extended_grid(&file) {
+ assert!(!anlz::has_section(&out, b"PQT2").unwrap());
+ written.push(out);
+ }
+ if let Ok(out) = anlz::write_waveform(&file, &tag, &columns) {
+ assert_eq!(anlz::waveform_entries(&out, &tag).unwrap(), Some(columns));
+ written.push(out);
+ }
+ for out in written.iter().chain([&file]) {
+ let _ = (anlz::read_path(out), anlz::cues(out), anlz::preview(out), anlz::detail(out));
+ }
}
/// A waveform the format can't hold, or a tag that isn't a waveform's, is refused.
diff --git a/crates/loclib-plugin/README.md b/crates/loclib-plugin/README.md
index 1c71bb2c..7d4dbbb5 100644
--- a/crates/loclib-plugin/README.md
+++ b/crates/loclib-plugin/README.md
@@ -160,9 +160,9 @@ manifest contributes for it. A plugin has a family's feature once it implements
No method of its own: an analyzer's fields may also be a beat grid (`beats`: `[{beat_number,
bpm, time_ms}]`), phrases (`phrases`: `{mood, phrases: [{kind, start_beat, end_beat,
fill_beat?}]}`) or a waveform (`waveform`: `{blob, columns_per_sec}`, the file holding three bytes
-a column, its lows, mids and highs, 0-255, `Bands` packed), one of each at most, or `null`.
-`FieldValue::beats`, `FieldValue::phrases` and `FieldValue::waveform` make them and `Output` reads
-them. Every plugin the SDK serves has the feature, and a host without it refuses a manifest
+a column, its lows, mids and highs, 0-255, `Bands` packed, no more than a day of audio), one
+of each at most, or `null`. `FieldValue::beats`, `FieldValue::phrases` and `FieldValue::waveform`
+make them and `Output` reads them. Every plugin the SDK serves has the feature, and a host without it refuses a manifest
declaring such a field. They're not columns: the host keeps them apart, draws them where a drive
has none and writes them into analysis files only when the user asks. An operation's
`set_analysis` edit needs `analysis:plan`.
From 924331c3a25563c4b4a79138e952f591b127a293 Mon Sep 17 00:00:00 2001
From: botandbang <337765928+botandbang@users.noreply.github.com>
Date: Wed, 21 Oct 2026 08:09:58 -0400
Subject: [PATCH 4/5] tests: the SDK's events and services tests expect the
analysis feature every plugin it serves has
---
crates/loclib-plugin/src/sdk/tests/events.rs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/crates/loclib-plugin/src/sdk/tests/events.rs b/crates/loclib-plugin/src/sdk/tests/events.rs
index 29c5f2b3..f5c85e2e 100644
--- a/crates/loclib-plugin/src/sdk/tests/events.rs
+++ b/crates/loclib-plugin/src/sdk/tests/events.rs
@@ -34,7 +34,7 @@ fn events_are_handled_in_order_after_hello() {
|drive: &str| EventNotify { event: EventName::DriveMounted, drive: Some(drive.into()), payload: json!({}) };
s.host.notify(method::EVENT_NOTIFY, &event("early"));
let reply: HelloReply = s.host.request(method::HELLO, &hello(PROTOCOL_VERSION, &[])).unwrap();
- assert_eq!(reply.features, ["events", "blobs", "net", "choose"]);
+ assert_eq!(reply.features, ["analysis", "events", "blobs", "net", "choose"]);
for drive in ["A", "B"] {
s.host.notify(method::EVENT_NOTIFY, &event(drive));
}
@@ -83,7 +83,7 @@ fn a_service_runs_until_cancelled() {
};
let s = start_with(with_service(), Hooks::new().on(service));
let reply: HelloReply = s.host.request(method::HELLO, &hello(PROTOCOL_VERSION, &[])).unwrap();
- assert_eq!(reply.features, ["services", "blobs", "net", "choose"]);
+ assert_eq!(reply.features, ["analysis", "services", "blobs", "net", "choose"]);
let e = s.call(method::SERVICE_START, json!({ "service": "other" })).unwrap_err();
assert_eq!(e.message, "invalid params: the manifest declares no service \"other\"");
let running = s.host.send_request(method::SERVICE_START, &json!({ "service": "beat" })).unwrap();
From ea96e8faa5176d7f1384050fcf59a19495374740 Mon Sep 17 00:00:00 2001
From: botandbang <337765928+botandbang@users.noreply.github.com>
Date: Wed, 21 Oct 2026 15:00:07 -0400
Subject: [PATCH 5/5] tests: analyze --apply, the analysis plan, analyzers'
structured answers and analysis edits' warnings and replays have properties
---
.../tests/it/commands/analysis.rs | 176 ++++++++++++++
crates/locallibrary/tests/it/analysis_edit.rs | 105 +++++++-
.../locallibrary/tests/it/plugins/analysis.rs | 226 ++++++++++++++++++
3 files changed, 505 insertions(+), 2 deletions(-)
diff --git a/apps/locallibrary/tests/it/commands/analysis.rs b/apps/locallibrary/tests/it/commands/analysis.rs
index f2f1a21b..f0823a0f 100644
--- a/apps/locallibrary/tests/it/commands/analysis.rs
+++ b/apps/locallibrary/tests/it/commands/analysis.rs
@@ -331,3 +331,179 @@ fn analyze_writes_a_grid_into_the_analysis_files() {
);
assert_eq!(nothing["error"]["data"]["kind"], "invalid", "{nothing}");
}
+
+/// For any of grid, phrases and waveform asked of the test plugin's `heard` (one of two analyzers
+/// working out a grid), overwriting or not, planned or applied, in text or `--json`, with a track
+/// whose audio fails it or none: `analyze --apply` runs it and writes what it worked out where
+/// it can, saying what it left out (a grid only over the tracks' with --overwrite, phrases on the
+/// tracks' grid, no waveform over rekordbox's colour one), and changes nothing on a dry run. A
+/// key or BPM asked with them, --min-confidence, an analyzer not named or one that doesn't work
+/// it out is refused before anything runs, and every track failing is an error. A key or BPM
+/// asked alone is written from the analyzer chosen for it (the sound plugin's, of it and
+/// `heard`), as `plugins.apply_fields` plans it.
+#[cfg(debug_assertions)]
+#[hegel::test(test_cases = 40, suppress_health_check = [hegel::HealthCheck::TooSlow])]
+fn analyze_apply_writes_the_parts_asked_where_it_can(tc: hegel::TestCase) {
+ use hegel::generators as gs;
+ use locallibrary::plugin::AnalysisPart;
+ let mut env = Env::new();
+ let analyzers = "\n[[contributes.analyzers]]\nname = \"heard\"\ntitle = \"Heard\"\nversion = \"1\"\n\
+ fields = { grid = \"beats\", phrases = \"phrases\", wave = \"waveform\", tone = \"string\" }\n\
+ roles = { key = \"tone\" }\n\n\
+ [[contributes.analyzers]]\nname = \"beat\"\ntitle = \"Beat\"\nversion = \"1\"\n\
+ fields = { beats = \"beats\" }\n";
+ env.plugins(&[&(crate::support::echo_plugin() + analyzers)]);
+ let (overwrite, dry_run, as_json) = (tc.draw(gs::booleans()), tc.draw(gs::booleans()), tc.draw(gs::booleans()));
+ let flags = |mut args: Vec<&'static str>| {
+ args.extend(overwrite.then_some("--overwrite"));
+ args.extend(dry_run.then_some("--dry-run"));
+ if as_json {
+ args.insert(0, "--json");
+ }
+ args
+ };
+ if tc.draw(gs::weighted_booleans(0.25)) {
+ // The sound plugin heard the first track at double its tempo in its parallel key.
+ let root = analysed(&env, false);
+ let before = Device::open(&root).unwrap().library().unwrap().tracks;
+ let bpm = tc.draw(gs::booleans());
+ let key = !bpm || tc.draw(gs::booleans());
+ let roles: Vec<&str> = [(key, "key"), (bpm, "bpm")].into_iter().filter(|r| r.0).map(|r| r.1).collect();
+ let listed = roles.join(",");
+ let mut args = flags(vec!["--yes", "analyze"]);
+ args.extend([path(&root), "--apply", &listed]);
+ let r = env.run(&args);
+ assert_eq!(r.code, 0, "{args:?}\n{}\n{}", r.out, r.err);
+ let written = usize::from(overwrite);
+ let summary = format!("write {} from Tempo, key and loudness to {written} track(s)", roles.join(" and "));
+ if as_json {
+ let v: Value = serde_json::from_str(&r.out).unwrap();
+ assert_eq!(v["plan"]["operation"], "plugins.apply_fields", "{v}");
+ assert_eq!(v["plan"]["summary"][0], summary.as_str(), "{v}");
+ } else {
+ assert!(r.out.contains("tempo-key-loudness (sound): 0 tracks analyzed, 2 already"), "{}", r.out);
+ assert!(r.out.contains(&summary), "{}", r.out);
+ }
+ let after = Device::open(&root).unwrap().library().unwrap().tracks;
+ let applied = written > 0 && !dry_run;
+ let heard_key = Camelot::parse(&before[0].key).unwrap().parallel().note();
+ assert_eq!(after[0].key, if applied && key { heard_key } else { before[0].key.clone() });
+ assert_eq!(after[0].bpm, if applied && bpm { before[0].bpm * 2.0 } else { before[0].bpm });
+ assert_eq!((&after[1].key, after[1].bpm), (&before[1].key, before[1].bpm));
+ return;
+ }
+ let root = env.demo("A");
+ let device = Device::open(&root).unwrap();
+ let tracks = device.library().unwrap().tracks;
+ let before: Vec<_> = tracks.iter().map(|t| locallibrary::anlz::load(&device, t).beats).collect();
+ let grid: Vec