diff --git a/apps/locallibrary/CHANGELOG.md b/apps/locallibrary/CHANGELOG.md index 84e5c9f8..24261a48 100644 --- a/apps/locallibrary/CHANGELOG.md +++ b/apps/locallibrary/CHANGELOG.md @@ -285,6 +285,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 beeef420..4750505a 100644 --- a/apps/locallibrary/docs/commands.md +++ b/apps/locallibrary/docs/commands.md @@ -139,7 +139,7 @@ its drives. | `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 import FILE [] [--format FORMAT] [--option KEY=VALUE]... [--dry-run]` | Read another program's file with a plugin's importer: keys, BPMs, cues and My Tags added to the tracks it finds where they have none, plays kept as sessions, see [Plugins](plugins.md#import-formats) | -| `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/editing.md b/apps/locallibrary/docs/editing.md index 999ca3c5..0ad6b366 100644 --- a/apps/locallibrary/docs/editing.md +++ b/apps/locallibrary/docs/editing.md @@ -241,9 +241,9 @@ replay makes edits again in whichever lacks them. The recorded edits travel with source drive's onto the drives it updates, so `refresh` makes edits made on any of the library's drives again. A push or pack, which gives a drive a library from a snapshot or a selection, forgets the edits recorded for the one it held (its plan says so). `edits.json` says which version of its -format it is, the lowest its edits need (artwork edits, and a BPM, album artist, ISRC or release -date, need the newest): a locallibrary older than that refuses to read it or add to it (and says -so) rather than misreading it. Builds from before it had a version read it as a plain +format it is, the lowest its edits need (artwork edits and a BPM, album artist, ISRC or release +date need 3; analysis edits and cue edits setting several cues need 4, the newest): a locallibrary +older than that refuses to read it or add to it (and says so) rather than misreading it. Builds from before it had a version read it as a plain list: they change the drive, then fail to record the edit. Update every copy (the CLI and the app's sidecar) before editing a drive this version has edited. diff --git a/apps/locallibrary/docs/plugins.md b/apps/locallibrary/docs/plugins.md index d88b3639..235729f9 100644 --- a/apps/locallibrary/docs/plugins.md +++ b/apps/locallibrary/docs/plugins.md @@ -45,6 +45,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 91e9fffa..5f91ed17 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"; }; /** @@ -862,6 +877,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. */ @@ -2176,6 +2246,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; @@ -2905,13 +2998,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,7 +3037,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 = { @@ -2987,37 +3078,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[]; @@ -3180,25 +3261,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. */ @@ -4293,6 +4362,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. */ @@ -7685,7 +7769,14 @@ export type Analyzer = { display?: { [key: string]: FieldDisplay }; }; -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"; /** * How hosts show a field's values, each part optional. Without them a number shows to two @@ -9092,6 +9183,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 b92d6b07..f3ac59cc 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": [], @@ -7073,6 +7131,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." @@ -7724,6 +7830,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": { @@ -11222,6 +11502,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": { @@ -13520,52 +13867,26 @@ "type": "null" } ] - }, - "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", @@ -13710,6 +14031,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`])." } ] }, @@ -13821,123 +14147,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", @@ -14477,36 +14716,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", @@ -18286,6 +18531,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." @@ -29384,6 +29677,7 @@ "tags:plan", "cues:plan", "artwork:plan", + "analysis:plan", "events:drives", "events:jobs", "events:plans" @@ -29822,13 +30116,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`)." + } ] }, "FieldDisplay": { diff --git a/apps/locallibrary/docs/sidecar.md b/apps/locallibrary/docs/sidecar.md index 0c340f3f..12efbc80 100644 --- a/apps/locallibrary/docs/sidecar.md +++ b/apps/locallibrary/docs/sidecar.md @@ -376,6 +376,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 fc849713..40523503 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::{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 9e0b4b0a..41905fbd 100644 --- a/apps/locallibrary/src/plugins.rs +++ b/apps/locallibrary/src/plugins.rs @@ -407,6 +407,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::EventsDrives => "hear of drives plugged in, unplugged, changed and saved into the store", Capability::EventsJobs => "hear of the sidecar's requests finishing", Capability::EventsPlans => "hear of changes applied, and what they were", diff --git a/apps/locallibrary/src/rpc/methods/plugins.rs b/apps/locallibrary/src/rpc/methods/plugins.rs index e738fb24..cf186b38 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; @@ -89,6 +90,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); panels::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 dfc11cc3..c2d59b89 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, @@ -1849,6 +1860,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; @@ -2550,7 +2562,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()); @@ -2563,7 +2575,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)); @@ -2575,6 +2587,7 @@ impl App { drive: self.queue.drive.clone(), root, analysis: TrackAnalysis::default(), + from_plugins: None, art: None, phrases: Vec::new(), matches: None, @@ -2897,6 +2910,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 3ab95569..68a7a613 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 ad5e707a..c0a6cdd7 100644 --- a/apps/locallibrary/src/tui/ui.rs +++ b/apps/locallibrary/src/tui/ui.rs @@ -717,7 +717,7 @@ fn field_cell(v: &FieldValue, hint: Option<&FieldDisplay>) -> 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::current().warning)) @@ -1467,6 +1467,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 96bab43b..906d461e 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..f0823a0f 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,224 @@ 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}"); +} + +/// 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 = + (0..16).map(|i| json!({ "beat_number": i % 4 + 1, "bpm": 125.0, "time_ms": 30 + i * 480 })).collect(); + let phrases = json!({ "mood": "mid", "phrases": [{ "kind": "intro", "start_beat": 1, "end_beat": 17 }] }); + let failing = match tc.draw(gs::weighted_booleans(0.3)) { + true => tc.draw(gs::integers::().min_value(1).max_value(tracks.len())), + false => 0, + }; + // What the test plugin's `heard` answers: what's in the audio file, the first `failing` + // tracks' not JSON. + for (i, t) in tracks.iter().enumerate() { + let answer = json!({ + "grid": grid, + "phrases": phrases, + "wave": { "blob": "w.bands", "columns_per_sec": 150.0 }, + "blobs": { "w.bands": vec![9u8; 3 * 300] }, + }); + let audio = if i < failing { "not JSON".to_string() } else { answer.to_string() }; + std::fs::write(paths::to_fs(&root, &paths::key(&t.file_path)), audio).unwrap(); + } + let at = path(&root); + let heard = ["--plugin", "echo", "--analyzer", "heard"]; + let refusal = tc.draw(gs::weighted_booleans(0.3)).then(|| tc.draw(gs::integers::().max_value(3))); + let refused: Option<(Vec<&str>, &str)> = match refusal { + None => None, + Some(0) => Some((vec!["--apply", "bpm,phrases"], "write a key and BPM in a run of their own")), + Some(1) => Some((vec!["--apply", "grid", "--min-confidence", "0.5"], "--min-confidence is for a key and BPM")), + Some(2) => { + Some((vec!["--apply", "grid"], "several analyzers work out grid: say which with --plugin and --analyzer")) + } + Some(_) => { + Some((vec!["--analyzer", "beat", "--apply", "waveform"], "no plugin's analyzer here works out waveform")) + } + }; + if let Some((args, why)) = refused { + let err = env.fails(2, &[&["analyze", at][..], &args].concat()); + assert!(err.contains(why), "{err}"); + assert!(!err.contains("analyzed"), "nothing ran: {err}"); + return; + } + let mut parts: Vec = + [(AnalysisPart::Grid, 0.7), (AnalysisPart::Phrases, 0.5), (AnalysisPart::Waveform, 0.3)] + .into_iter() + .filter(|(_, p)| tc.draw(gs::weighted_booleans(*p))) + .map(|(part, _)| part) + .collect(); + if parts.is_empty() { + parts.push(AnalysisPart::Grid); + } + let asked: Vec<&str> = parts.iter().map(|p| p.name()).collect(); + let listed = asked.join(","); + let mut args = flags(vec!["--yes", "analyze"]); + args.extend([at, "--apply", &listed]); + args.extend(heard); + let r = env.run(&args); + let analysed = tracks.len() - failing; + if analysed == 0 { + assert_eq!(r.code, 1, "{}", r.err); + let said = if as_json { &r.out } else { &r.err }; + assert!(said.contains(&format!("every track failed ({failing})")), "{said}"); + return; + } + assert_eq!(r.code, 0, "{}\n{}", r.out, r.err); + let wants = |p| parts.contains(&p); + // The demo's tracks have a grid, phrases and rekordbox's colour waveform: only a grid with + // --overwrite is written (with the phrases on it), a waveform leaving the track out. + let written = match overwrite && wants(AnalysisPart::Grid) && !wants(AnalysisPart::Waveform) { + true => analysed, + false => 0, + }; + let what: Vec<&str> = AnalysisPart::ALL.into_iter().filter(|p| wants(*p)).map(AnalysisPart::name).collect(); + let summary = format!("write the {} from Heard to {written} track(s)", what.join(" and ")); + let mut warnings = Vec::new(); + if failing > 0 { + warnings.push(format!("{failing} track(s) aren't analysed by Heard yet")); + } + if !overwrite { + warnings.push(format!("{} of what's asked the tracks have already", analysed * parts.len())); + } + if overwrite && wants(AnalysisPart::Phrases) && !wants(AnalysisPart::Grid) { + warnings.push(format!("{analysed} track(s)' phrases left out")); + } + if overwrite && wants(AnalysisPart::Waveform) { + warnings.push(format!("{analysed} track(s) left out, e.g. ")); + } + if as_json { + let v: Value = serde_json::from_str(&r.out).unwrap(); + assert_eq!(v["plan"]["operation"], "plugins.apply_analysis", "{v}"); + assert_eq!(v["plan"]["summary"][0], summary.as_str(), "{v}"); + assert_eq!(v["applied"], json!(written > 0 && !dry_run), "{v}"); + let said = v["plan"]["warnings"].as_array().unwrap(); + for w in &warnings { + assert!(said.iter().any(|s| s.as_str().unwrap().starts_with(w.as_str())), "{w:?} in {said:?}"); + } + } else { + let ran = format!("heard (echo): {analysed} track{} analyzed", if analysed == 1 { "" } else { "s" }); + assert!(r.out.contains(&ran) && r.out.contains(&summary), "{}", r.out); + assert_eq!(r.out.contains("nothing to change"), written == 0, "{}", r.out); + for w in &warnings { + assert!(r.out.contains(w.as_str()) || r.err.contains(w.as_str()), "{w:?} in\n{}\n{}", r.out, r.err); + } + } + let device = Device::open(&root).unwrap(); + let applied = written > 0 && !dry_run; + for (i, t) in device.library().unwrap().tracks.iter().enumerate() { + let beats = locallibrary::anlz::load(&device, t).beats; + match applied && i >= failing { + true => assert_eq!((beats.len(), beats[1].time_ms, t.bpm), (16, 510, 125.0)), + false => assert_eq!(beats, before[i]), + } + } +} diff --git a/crates/locallibrary/src/analysis_edit.rs b/crates/locallibrary/src/analysis_edit.rs new file mode 100644 index 00000000..52a98738 --- /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 => crate::fsutil::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()?; + Ok((rewrite(t, &read(device, t)?, a, bands.as_ref())?, bands)) + }); + let ((changed, warnings), bands) = 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`), 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() }; + 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 34684a1a..4229e35e 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(crate) use write::MAX_COMMENT; pub use write::{CueFile, CuePoint, RawCue, read_cue_points, shift_times, write_cue_points}; @@ -71,6 +85,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(); @@ -262,7 +281,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") @@ -289,11 +308,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") @@ -321,6 +345,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) } @@ -408,7 +437,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| crate::fsutil::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(); @@ -432,6 +462,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..da9e6796 --- /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`]) 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::*; + +/// 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..b53f0071 --- /dev/null +++ b/crates/locallibrary/src/anlz/waves.rs @@ -0,0 +1,147 @@ +//! 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) +} + +/// 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 +/// 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 672c2550..0cb625c1 100644 --- a/crates/locallibrary/src/anlz/write.rs +++ b/crates/locallibrary/src/anlz/write.rs @@ -147,11 +147,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/src/artwork.rs b/crates/locallibrary/src/artwork.rs index 9136b45e..3f2b0e7d 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 40280e76..4b504411 100644 --- a/crates/locallibrary/src/bin/test-plugin/main.rs +++ b/crates/locallibrary/src/bin/test-plugin/main.rs @@ -12,14 +12,15 @@ //! 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`), and a panel other //! than echo's as its `panel` setting does (see `Test::render_panel`); a command given a `view` -//! answers it as it is (once its `blobs` are written); an analyzer -//! named `order` answers fields `a` (null for a one-byte audio file) then `b`, or fails naming an -//! empty audio file; its exporter names the playlists it's given in a note, and would write to -//! `outside`, or writes `extra` too without saying so first, and given a set or a mix writes its -//! tracklist; its importer answers a JSON file as it is (in a blob with the `blob` option); it -//! logs each event it's told of and tells the user it heard it, suggesting echo's `playlist` (or -//! tells them what its payload's `tell` says); and its services run as their names say (see -//! `plugin::serve_service`). +//! answers it as it is (once its `blobs` are written); an analyzer 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; its exporter names the playlists +//! it's given in a note, and would write to `outside`, or writes `extra` too without saying so +//! first, and given a set or a mix writes its tracklist; its importer answers a JSON file as it is +//! (in a blob with the `blob` option); it logs each event it's told of and tells the user it heard +//! it, suggesting echo's `playlist` (or tells them what its payload's `tell` says); and its +//! services run as their names say (see `plugin::serve_service`). //! `--manifest ` serves another manifest, `--protocol ` answers `hello` with //! another protocol and stops, `--silent` never answers, `--paused ` logs `paused` and //! serves only once `` is there, `--wrong-fields` answers fields of the wrong type, and diff --git a/crates/locallibrary/src/bin/test-plugin/plugin.rs b/crates/locallibrary/src/bin/test-plugin/plugin.rs index 3b2a0873..4e1da60c 100644 --- a/crates/locallibrary/src/bin/test-plugin/plugin.rs +++ b/crates/locallibrary/src/bin/test-plugin/plugin.rs @@ -177,11 +177,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 10ad78ba..c69bcc4a 100644 --- a/crates/locallibrary/src/cue_edit.rs +++ b/crates/locallibrary/src/cue_edit.rs @@ -439,12 +439,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 128b3106..ebe2c5ca 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. @@ -1935,7 +1944,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 } => { @@ -2019,8 +2028,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)) } @@ -2193,11 +2222,13 @@ fn open_databases(device: &Device, warnings: &mut Vec) -> Result) -> Result { plan_writing(device, edits, opts, &[], ctx) } @@ -2211,14 +2242,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. @@ -2452,7 +2497,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()); @@ -2722,7 +2767,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>, } @@ -2742,6 +2788,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)) + } _ => {} } } @@ -2815,8 +2864,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()); @@ -3222,9 +3274,9 @@ 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`); 4 adds cue edits setting several - /// (`set_many`). Each is written only when an entry uses what it adds. A journal of a later - /// version is refused rather than misread or overwritten. + /// drop (`album_artist`, `bpm`, `isrc`, `release_date`); 4 adds `set_analysis` and cue edits + /// setting several (`set_many`). 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)] @@ -3265,43 +3317,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: 4 once one holds a cue edit setting several, else 3 once one - /// holds an artwork edit or a track detail 3 added, else 2, so a build that reads an earlier - /// one keeps editing a drive whose edits use nothing later. + /// The version `entries` need: 4 once one holds an analysis edit or a cue edit setting + /// several, 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_4 = |e: &Edit| matches!(e, Edit::Cues { change: crate::cue_edit::CueChange::SetMany(_), .. }); - 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 { .. } | Edit::Cues { change: crate::cue_edit::CueChange::SetMany(_), .. } => 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, }; - let edits = || entries.iter().flat_map(|e| &e.edits); - match (edits().any(needs_4), edits().any(needs_3)) { - (true, _) => VERSION, - (false, true) => 3, - (false, false) => 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`. @@ -3345,12 +3403,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 @@ -3360,7 +3418,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 b12061b8..92b7a0a9 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 cc84513a..8d1cb4aa 100644 --- a/crates/locallibrary/src/op.rs +++ b/crates/locallibrary/src/op.rs @@ -231,6 +231,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 143f164b..05710bf7 100644 --- a/crates/locallibrary/src/plugin.rs +++ b/crates/locallibrary/src/plugin.rs @@ -17,13 +17,15 @@ //! (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 -//! offers the user and makes the edits of itself (see [`Plugins::provide`]), as it does of what -//! importers read (see [`Plugins::import_plan`]). Plugins are told of what happens as the front -//! ends notice it ([`Plugins::deliver`]), and services run while a front end that stays open does +//! 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`]), as it does of what importers read (see +//! [`Plugins::import_plan`]). Plugins are told of what happens as the front ends notice it +//! ([`Plugins::deliver`]), and services run while a front end that stays open does //! ([`Plugins::run_services`]); what either tells the user, the front end shows //! ([`Options::notices`]), and nothing they suggest is planned unless the user chooses to. +mod analysis; mod analyzers; mod apply; mod blobs; @@ -56,6 +58,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; @@ -276,11 +279,14 @@ impl Plugins { analyzers.iter().find_map(|a| a.display.get(field)) } - /// 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..4a9baf34 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,44 @@ 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")) + })?; + let bands = anlz::at_detail_rate(&bands, w.columns_per_sec) + .map_err(|e| self.misbehaved(format!("its {name}: {e}")))?; + o.waveform = Some(bands); + } + } + } + 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.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/analysis_edit.rs b/crates/locallibrary/tests/it/analysis_edit.rs new file mode 100644 index 00000000..866d61c3 --- /dev/null +++ b/crates/locallibrary/tests/it/analysis_edit.rs @@ -0,0 +1,572 @@ +//! 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"); + strip_colour_overview(&root, &t); + (root, t) +} + +/// Takes rekordbox's colour overview (`PWV4`) out of the `.EXT` of `t` on the drive at `root`, +/// and its `.2EX` away: a waveform can then be written to it. +pub(crate) fn strip_colour_overview(root: &Path, t: &Track) { + 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(); +} + +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"); +} + +/// 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()); +} + +/// For any grid moved or kept, phrases or not, a waveform or not (on a track with or without an +/// `.EXT`), and cues on the old grid's beats: the plan warns of the cues left off the new grid +/// and of a waveform with no `.EXT` to hold its detail, and applied, a replay makes it again as +/// it was made: nothing more when the drive has it, the same files after rekordbox exports again +/// (the waveform from its staged copy), skipped with a warning once the track's `.DAT` is gone; +/// and a grid set over a moved one has no extended grid left to lose. +#[hegel::test(test_cases = 24, suppress_health_check = [HealthCheck::TooSlow])] +fn any_analysis_edit_warns_of_what_it_leaves_and_replays_as_made(tc: TestCase) { + let dir = tempfile::tempdir().unwrap(); + let root = common::demo_drive(dir.path(), "USB"); + let t = track(&root, "Demo Track 1"); + let name = t.display_name(); + let dat = paths::to_fs(&root, &paths::key(&t.analyze_path)); + let waved = tc.draw(gs::booleans()); + let no_ext = waved && tc.draw(gs::booleans()); + if waved { + strip_colour_overview(&root, &t); + } + if no_ext { + std::fs::remove_file(dat.with_extension("EXT")).unwrap(); + } + let grid = anlz::beat_grid(&std::fs::read(&dat).unwrap()).unwrap(); + let on_beats = tc.draw(gs::integers::().max_value(3)); + let mut cues: Vec = (0..on_beats) + .map(|i| CuePoint { hot_cue: Some(i as u8), start_ms: grid[4 * (i + 1)].time_ms, ..Default::default() }) + .collect(); + cues.push(CuePoint { start_ms: grid[2].time_ms + 100, ..Default::default() }); + let [d, e, _] = files(&root, &t); + std::fs::write(&dat, anlz::write_cue_points(&d.unwrap(), CueFile::Dat, &cues).unwrap()).unwrap(); + if let Some(e) = e { + std::fs::write(dat.with_extension("EXT"), anlz::write_cue_points(&e, CueFile::Ext, &cues).unwrap()).unwrap(); + } + let exported = common::library_files(&root); + + let phrases = (!no_ext && tc.draw(gs::booleans())).then(|| SongStructure { + mood: Mood::Mid, + phrases: vec![Phrase { kind: PhraseKind::Intro, start_beat: 1, end_beat: 9, fill_beat: None }], + }); + let mut shift = tc.draw(gs::sampled_from(vec![0u32, 20, 40])); + if shift == 0 && phrases.is_none() && !waved { + shift = 20; + } + let moved: Vec = grid.iter().map(|b| Beat { time_ms: b.time_ms + shift, ..*b }).collect(); + let b = bands(tc.draw(gs::integers::().min_value(1).max_value(600))); + let analysis = NewAnalysis { beats: Some(moved.clone()), phrases, waveform: waved.then(|| wave(&b)) }; + let p = plan(&root, &[set(&t, analysis)]).unwrap(); + let off = format!("cue(s) of {name} sat on beats of its old grid: they stay where they are, off the new one"); + let moved_cues = if shift > 0 { on_beats } else { 0 }; + let said = p.warnings.iter().find(|w| w.ends_with(&off)); + assert_eq!(said, (moved_cues > 0).then(|| format!("{moved_cues} {off}")).as_ref(), "{:?}", p.warnings); + let overview_only = format!("{name} has no .EXT analysis file, so only its overview waveform is written"); + assert_eq!(p.warnings.contains(&overview_only), no_ext, "{:?}", p.warnings); + edit::apply(&p, &mut Device::open_export(&root).unwrap(), &Ctx::silent()).unwrap(); + let edited = files(&root, &t); + assert_eq!(anlz::beat_grid(edited[0].as_ref().unwrap()).unwrap(), moved); + + let replay = |root: &Path| { + let mut d = Device::open_export(root).unwrap(); + let edits = edit::journal::edits(&d).unwrap(); + let opts = EditOptions { lenient: true, no_journal: true }; + let plan = edit::plan(&d, &edits, &opts, &Ctx::silent()).unwrap(); + edit::apply(&plan, &mut d, &Ctx::silent()).unwrap() + }; + match tc.draw(gs::integers::().max_value(3)) { + 0 => { + let report = replay(&root); + assert_eq!(files(&root, &t), edited, "as it is already"); + assert!(report.warnings.iter().all(|w| !w.contains("skipped")), "{:?}", report.warnings); + } + 1 => { + for (key, bytes) in &exported { + std::fs::write(paths::to_fs(&root, key), bytes).unwrap(); + } + replay(&root); + assert_eq!(files(&root, &t), edited); + } + 2 => { + std::fs::remove_file(&dat).unwrap(); + let report = replay(&root); + let skipped = "skipped an edit that no longer applies (set the beat grid"; + assert!(report.warnings.iter().any(|w| w.starts_with(skipped)), "{:?}", report.warnings); + assert_eq!(files(&root, &t)[1..], edited[1..]); + } + _ => { + let again: Vec = moved.iter().map(|b| Beat { time_ms: b.time_ms + 7, ..*b }).collect(); + let p = plan(&root, &[set(&t, NewAnalysis { beats: Some(again.clone()), ..Default::default() })]).unwrap(); + // Its extended grid went with the first grid that moved. + let loses = p.warnings.iter().any(|w| w.contains("loses its extended beat grid (PQT2)")); + assert_eq!(loses, shift == 0 && !no_ext, "{:?}", p.warnings); + edit::apply(&p, &mut Device::open_export(&root).unwrap(), &Ctx::silent()).unwrap(); + assert_eq!(anlz::beat_grid(files(&root, &t)[0].as_ref().unwrap()).unwrap(), again); + } + } +} diff --git a/crates/locallibrary/tests/it/edit.rs b/crates/locallibrary/tests/it/edit.rs index cf4204a4..8bdf2599 100644 --- a/crates/locallibrary/tests/it/edit.rs +++ b/crates/locallibrary/tests/it/edit.rs @@ -13,6 +13,7 @@ use std::fs; use std::path::Path; use crate::common::{apply, replay}; +use locallibrary::cue_edit::{CueChange, CuePosition, SetCue}; use locallibrary::device::{Device, LineageOp}; use locallibrary::edit::{self, Edit, EditOptions, TrackFields}; use locallibrary::model::TrackColor; @@ -459,6 +460,28 @@ fn the_journal_has_a_version() { assert_eq!(fs::read_to_string(&file).unwrap(), newer); } +/// A journal is written at the lowest version its edits need: a track detail 3 added makes it 3, +/// and a cue edit setting several makes it 4 (as an analysis edit does, see `analysis_edit`), so +/// a build that reads 3 keeps editing the drive until an edit needs 4. +#[test] +fn the_journal_is_written_at_the_version_its_edits_need() { + let dir = tempfile::tempdir().unwrap(); + let root = common::demo_drive(dir.path(), "Main"); + let version = || { + let file = Device::open_export(&root).unwrap().state_dir().join("edits.json"); + serde_json::from_slice::(&fs::read(file).unwrap()).unwrap()["version"].clone() + }; + let track = pdb_library(&root).tracks[0].clone(); + let bpm = TrackFields { bpm: Some(track.bpm + 1.0), ..Default::default() }; + apply(&root, &[Edit::EditTracks { tracks: ids(&[track.id]), fields: bpm }]); + assert_eq!(version(), 3); + let at = + |ms| SetCue { hot_cue: None, at: CuePosition::Ms(ms), snap: false, loop_beats: None, name: None, color: None }; + let change = CueChange::SetMany(vec![at(1_000), at(2_000)]); + apply(&root, &[Edit::Cues { tracks: vec![track.file_path.clone()], change }]); + assert_eq!(version(), 4); +} + #[test] fn a_playlist_missing_from_onelibrary_is_skipped_there_with_a_warning() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/locallibrary/tests/it/main.rs b/crates/locallibrary/tests/it/main.rs index 04128ffb..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; @@ -49,6 +50,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/plugins.rs b/crates/locallibrary/tests/it/plugins.rs index 7efc1f8d..92448340 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..bc32d901 --- /dev/null +++ b/crates/locallibrary/tests/it/plugins/analysis.rs @@ -0,0 +1,706 @@ +//! 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, anlz::at_detail_rate(&Bands::unpack(&bytes).unwrap(), rate).ok()); + 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)]); +} + +/// 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); +} + +/// Any beat grid, phrases and waveform an analyzer answers are kept as it gave them (nulls and an +/// empty grid as nothing), or fail the track, saying why, keeping nothing: a beat out of its bar, +/// of a tempo out of range or not after the one before, a waveform whose file isn't there or +/// isn't three bytes a column. +#[hegel::test(test_cases = 20, suppress_health_check = [hegel::HealthCheck::TooSlow])] +fn any_structured_answer_is_kept_or_fails_the_track_saying_why(tc: hegel::TestCase) { + use hegel::generators as gs; + let f = Fixture::new(|dir| vec![spec(dir, &structured(), &[])], Options::default()); + let beat = |n: u16, bpm: f64, ms: u32| Beat { beat_number: n, bpm, time_ms: ms }; + let wave = |blob: &str| json!({ "blob": blob, "columns_per_sec": 150.0 }); + let columns = tc.draw(gs::integers::().min_value(1).max_value(200)); + let packed = Bands((0..columns).map(|i| [i as u8, 7, 9]).collect()).pack(); + let (answer, why): (Value, Option) = match tc.draw(gs::integers::().max_value(6)) { + 0 => { + let grid = tc.draw(gs::sampled_from(vec![json!(null), json!([])])); + (json!({ "grid": grid, "phrases": null, "wave": null, "tempo": 1 }), None) + } + 1 => (json!({ "phrases": phrases() }), None), + 2 => { + let n = tc.draw(hegel::one_of!(gs::just(0u16), gs::integers::().min_value(5))); + ( + json!({ "grid": [beat(1, 120.0, 0), beat(n, 120.0, 500)] }), + Some(format!("its grid: beat 2 is beat {n} of its bar: 1-4")), + ) + } + 3 => { + let bpm = tc.draw(hegel::one_of!( + gs::floats::().min_value(0.0).max_value(0.004), + gs::floats::().min_value(655.36).max_value(1e9) + )); + ( + json!({ "grid": [beat(1, bpm, 0)] }), + Some(format!("its grid: beat 1's tempo, {bpm} BPM, isn't 0.01-655.35")), + ) + } + 4 => { + let ms = tc.draw(gs::integers::().max_value(10_000)); + let back = tc.draw(gs::integers::().max_value(ms)); + ( + json!({ "grid": [beat(1, 120.0, ms), beat(2, 120.0, back)] }), + Some("its grid: beat 2 isn't after the one before it".into()), + ) + } + 5 => (json!({ "wave": wave("gone.bands") }), Some("its wave: its file \"gone.bands\" isn't there".into())), + _ => { + let mut bytes = tc.draw(gs::binary().max_size(40)); + if !bytes.is_empty() && bytes.len().is_multiple_of(3) { + bytes.pop(); + } + let answer = json!({ "wave": wave("w.bands"), "blobs": { "w.bands": bytes } }); + (answer, Some("its wave isn't three bytes a column, at least one column".into())) + } + }; + // The other track's answer is always kept: a grid and a waveform. + let kept = json!({ "grid": grid(8, 0), "wave": wave("w.bands"), "blobs": { "w.bands": packed } }); + let tracks = f.hear_all(&[answer.clone(), kept]); + let analyzed = f.plugins.analyze("echo", "heard", "USB", None, &f.store, &Ctx::silent()).unwrap(); + let overlays = OverlayStore::in_store(&f.store); + let (first, second) = (overlays.get(&f.audio(&tracks[0]), &heard()), overlays.get(&f.audio(&tracks[1]), &heard())); + let second = second.unwrap(); + assert_eq!((second.beats, second.waveform.map(|w| w.0.len())), (Some(grid(8, 0)), Some(columns))); + match why { + Some(why) => { + assert_eq!((analyzed.analyzed, analyzed.failed), (1, 1)); + let error = &analyzed.failures[0].error; + assert!(error.contains(&why), "{error}"); + assert_eq!(first, None); + } + None => { + assert_eq!((analyzed.analyzed, analyzed.failed), (2, 0), "{:?}", analyzed.failures); + let phrases = answer.get("phrases").filter(|p| !p.is_null()).map(|_| phrases()); + assert_eq!(first.map(|o| o.phrases), phrases.map(Some), "nothing to keep, nothing kept"); + } + } +} + +/// For any of what an analyzer kept for each track (a grid like the drive's or another, phrases, +/// a waveform, or nothing yet: the track failed it or it answered none of them), parts asked for, overwriting or not, of every track or one, and +/// a track's files that can take a waveform or can't: the plan writes to each track just what's +/// asked that it gives, over what the track has only when overwriting, phrases only on the grid +/// they count and no waveform where it can't go (leaving the track out), saying what it left out +/// and why; applied, the drive has what it planned. +#[hegel::test(test_cases = 50, suppress_health_check = [hegel::HealthCheck::TooSlow])] +fn any_kept_analysis_is_planned_as_asked_and_left_out_as_said(tc: hegel::TestCase) { + use crate::analysis_edit::strip_colour_overview; + use hegel::generators as gs; + let f = Fixture::new(|dir| vec![spec(dir, &structured(), &[])], Options::default()); + let device = locallibrary::Device::open_export(&f.drive).unwrap(); + let tracks = common::library(&f.drive).tracks; + let own = grid(64, 25); + let mut answers = Vec::new(); + // Each track's: analysed, its answer's grid, phrases and waveform, its files taking one. + let mut drawn = Vec::new(); + for t in &tracks { + let on_drive = anlz::load(&device, t).beats; + let analysed = tc.draw(gs::weighted_booleans(0.8)); + let beats = tc.draw(gs::sampled_from(vec![None, Some(on_drive), Some(own.clone())])); + let (phrased, waved) = (tc.draw(gs::weighted_booleans(0.7)), tc.draw(gs::booleans())); + let takes_wave = tc.draw(gs::booleans()); + if takes_wave { + strip_colour_overview(&f.drive, t); + } + let mut answer = json!({ "grid": beats, "tempo": 120.0 }); + if phrased { + answer["phrases"] = json!(phrases()); + } + if waved { + answer["wave"] = json!({ "blob": "w.bands", "columns_per_sec": 150.0 }); + answer["blobs"] = json!({ "w.bands": Bands(vec![[1, 2, 3]; 300]).pack() }); + } + // An audio file that isn't the answer's JSON fails the track: nothing kept for it. + answers.push(if analysed { answer } else { json!("not an answer") }); + drawn.push((analysed, beats, phrased, waved, takes_wave)); + } + f.hear_all(&answers); + f.plugins.analyze("echo", "heard", "USB", None, &f.store, &Ctx::silent()).unwrap(); + let device = locallibrary::Device::open_export(&f.drive).unwrap(); + let before: Vec<_> = tracks.iter().map(|t| anlz::load(&device, t)).collect(); + + let mut parts: Vec = + AnalysisPart::ALL.into_iter().filter(|_| tc.draw(gs::weighted_booleans(0.6))).collect(); + if parts.is_empty() { + parts.push(tc.draw(gs::sampled_from(AnalysisPart::ALL.to_vec()))); + } + let overwrite = tc.draw(gs::weighted_booleans(0.7)); + let only = tc.draw(gs::optional(gs::integers::().max_value(tracks.len() - 1))); + let req = ApplyAnalysis { + plugin: "echo".into(), + analyzer: "heard".into(), + drive: "USB".into(), + parts: parts.clone(), + tracks: only.map(|i| vec![tracks[i].id]), + overwrite, + }; + let planned = f.plugins.plan_apply_analysis(&req, &f.store, &Ctx::silent()).unwrap(); + + let wants = |p| parts.contains(&p); + let (mut unanalysed, mut had, mut other_grid, mut refused) = (0, 0, 0, 0); + // Each written track's new grid and phrases. + let mut written: Vec<(usize, Option>, bool, bool)> = Vec::new(); + for (i, (analysed, beats, phrased, waved, takes_wave)) in drawn.iter().enumerate() { + if only.is_some_and(|o| o != i) { + continue; + } + // An answer with none of them keeps no overlay: the plan has nothing of it. + if !analysed || (beats.is_none() && !phrased && !waved) { + unanalysed += 1; + continue; + } + let drive = &before[i]; + let mut take = |wanted: bool, given: bool, has: bool| { + had += usize::from(wanted && given && !overwrite && has); + wanted && given && (overwrite || !has) + }; + let grid_in = take(wants(AnalysisPart::Grid), beats.is_some(), !drive.beats.is_empty()); + let ends_with = if grid_in { beats.clone().unwrap() } else { drive.beats.clone() }; + let counts = beats.as_ref().is_none_or(|b| *b == ends_with); + let phrases_asked = take(wants(AnalysisPart::Phrases), *phrased, drive.structure.is_some()); + other_grid += usize::from(phrases_asked && !counts); + let phrases_in = phrases_asked && counts; + let wave_in = take(wants(AnalysisPart::Waveform), *waved, drive.preview.is_some() || drive.detail.is_some()); + if wave_in && !takes_wave { + refused += 1; + continue; + } + let grid_changes = grid_in && ends_with != drive.beats; + let phrases_change = phrases_in && drive.structure != Some(phrases()); + if grid_changes || phrases_change || wave_in { + written.push((i, grid_in.then_some(ends_with), phrases_in, wave_in)); + } + } + let what: Vec<&str> = AnalysisPart::ALL.into_iter().filter(|p| wants(*p)).map(AnalysisPart::name).collect(); + assert_eq!( + planned.summary[0], + format!("write the {} from Heard to {} track(s)", what.join(" and "), written.len()) + ); + let yet = format!("{unanalysed} track(s) aren't analysed by Heard yet"); + assert_eq!(planned.warnings.contains(&yet), unanalysed > 0, "{:?}", planned.warnings); + let kept = format!("{had} of what's asked the tracks have already, kept: overwrite to replace it"); + assert_eq!(planned.warnings.contains(&kept), had > 0, "{:?}", planned.warnings); + let counted = + format!("{other_grid} track(s)' phrases left out: they count the beats of Heard's grid, not the track's"); + assert_eq!(planned.warnings.contains(&counted), other_grid > 0, "{:?}", planned.warnings); + let left_out = planned.warnings.iter().find(|w| w.contains("track(s) left out, e.g. ")); + assert_eq!(left_out.is_some(), refused > 0, "{:?}", planned.warnings); + if let Some(w) = left_out { + assert!(w.starts_with(&format!("{refused} track(s) left out, e.g. ")) && w.contains("(PWV4)"), "{w}"); + } + let set: Vec<(&String, &NewAnalysis)> = planned + .plan + .recorded + .iter() + .filter_map(|e| match e { + Edit::SetAnalysis { track, analysis } => Some((track, analysis)), + _ => None, + }) + .collect(); + assert_eq!(set.len(), written.len()); + for ((track, analysis), (i, beats, phrases_in, wave_in)) in set.iter().zip(&written) { + assert_eq!(*track, &tracks[*i].file_path); + assert_eq!( + (&analysis.beats, analysis.phrases.is_some(), analysis.waveform.is_some()), + (beats, *phrases_in, *wave_in) + ); + } + assert_eq!(planned.noop, written.is_empty()); + if written.is_empty() { + return; + } + planned.apply(None, &Ctx::silent()).unwrap(); + let device = locallibrary::Device::open_export(&f.drive).unwrap(); + let after = common::library(&f.drive).tracks; + for (i, beats, phrases_in, wave_in) in &written { + let a = anlz::load(&device, &after[*i]); + assert_eq!(&a.beats, beats.as_ref().unwrap_or(&before[*i].beats)); + if beats.as_ref() == Some(&own) { + assert_eq!(after[*i].bpm, 120.0); + } + assert_eq!(a.structure, if *phrases_in { Some(phrases()) } else { before[*i].structure.clone() }); + if *wave_in { + let detail = a.detail.unwrap(); + assert_eq!((detail.columns.len(), detail.columns_per_sec), (300, Some(150.0))); + } + } +} 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..0679c081 --- /dev/null +++ b/crates/locallibrary/tests/it/props_anlz_write.rs @@ -0,0 +1,503 @@ +//! 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"); +} + +/// 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 { + 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 = 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(); + 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).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. +#[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() { diff --git a/crates/loclib-plugin/README.md b/crates/loclib-plugin/README.md index 1cdd9f83..01a98c0f 100644 --- a/crates/loclib-plugin/README.md +++ b/crates/loclib-plugin/README.md @@ -156,6 +156,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, 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`. + ### analyzers `track/analyze {analyzer, track: {id, file_path, audio_path, analyze_path?}, context}` -> @@ -164,7 +176,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. - `display` says how hosts show some of its fields (`FieldDisplay`), by field, each part optional: `unit` (said after a value), `precision` (a number's places, 0 to 6), `format` (`key` @@ -400,7 +412,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", "views"])); +assert_eq!(reply["features"], json!(["analysis", "commands", "blobs", "net", "choose", "views"])); 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 64ea8066..17e78311 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::events::FEATURE, hooks::operations::FEATURE, diff --git a/crates/loclib-plugin/src/hooks.rs b/crates/loclib-plugin/src/hooks.rs index 8ed8928d..ec8401bd 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 events; 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 abf7d76e..16d2d55d 100644 --- a/crates/loclib-plugin/src/hooks/analyzers.rs +++ b/crates/loclib-plugin/src/hooks/analyzers.rs @@ -141,10 +141,11 @@ impl FieldDisplay { /// The widest a column is. pub const MAX_WIDTH: u16 = 40; - /// `value` as hosts show it, by these hints: null is `-` (worked out, nothing to show). + /// `value` as hosts show it, by these hints: null is `-` (worked out, nothing to show), and so + /// is a beat grid, phrases or a waveform, which no column shows. pub fn show(&self, value: &FieldValue) -> String { let n = match value { - FieldValue::Null => return "-".into(), + FieldValue::Null | FieldValue::Structured(_) => return "-".into(), FieldValue::Boolean(b) => return if *b { "yes" } else { "no" }.into(), FieldValue::String(s) => return self.with_unit(s.clone()), FieldValue::Strings(all) => return self.with_unit(all.join(", ")), @@ -286,11 +287,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) @@ -301,6 +314,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 @@ -315,6 +333,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 4681bb79..c8d3f62e 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, FieldDisplay, FieldFormat, FieldType, FieldValue, Role, Track, }; diff --git a/crates/loclib-plugin/src/manifest.rs b/crates/loclib-plugin/src/manifest.rs index a0f2dc84..815fb1c1 100644 --- a/crates/loclib-plugin/src/manifest.rs +++ b/crates/loclib-plugin/src/manifest.rs @@ -230,6 +230,8 @@ pub enum Capability { TagsPlan, CuesPlan, ArtworkPlan, + /// Plan writing beat grids, phrases and waveforms into tracks' analysis files. + AnalysisPlan, /// Told of drives plugged in, unplugged, changed and saved into the store, and sets coming /// back (`event/notify`). EventsDrives, @@ -245,7 +247,7 @@ pub enum Capability { impl Capability { /// Every capability but `net:`. - pub const NAMED: [Capability; 19] = [ + pub const NAMED: [Capability; 20] = [ Capability::LibraryRead, Capability::AnalysisRead, Capability::ArtworkRead, @@ -262,6 +264,7 @@ impl Capability { Capability::TagsPlan, Capability::CuesPlan, Capability::ArtworkPlan, + Capability::AnalysisPlan, Capability::EventsDrives, Capability::EventsJobs, Capability::EventsPlans, @@ -286,6 +289,7 @@ impl Capability { Capability::TagsPlan => "tags:plan", Capability::CuesPlan => "cues:plan", Capability::ArtworkPlan => "artwork:plan", + Capability::AnalysisPlan => "analysis:plan", Capability::EventsDrives => "events:drives", Capability::EventsJobs => "events:jobs", Capability::EventsPlans => "events:plans", @@ -584,6 +588,7 @@ impl Manifest { return Err(invalid(at, format!("{cli:?} is used twice"))); } } + hooks::analysis::validate(self)?; hooks::commands::validate(self)?; hooks::events::validate(self)?; hooks::operations::validate(self)?; diff --git a/crates/loclib-plugin/src/manifest/tests.rs b/crates/loclib-plugin/src/manifest/tests.rs index 18b5f800..2eb302cc 100644 --- a/crates/loclib-plugin/src/manifest/tests.rs +++ b/crates/loclib-plugin/src/manifest/tests.rs @@ -1004,7 +1004,9 @@ fn display(tc: &TestCase, kind: FieldType) -> FieldDisplay { FieldType::Number | FieldType::Integer => { vec![FieldFormat::Duration, FieldFormat::Db, FieldFormat::Percent, FieldFormat::Meter] } - FieldType::Boolean | FieldType::Strings => Vec::new(), + FieldType::Boolean | FieldType::Strings | FieldType::Beats | FieldType::Phrases | FieldType::Waveform => { + Vec::new() + } }; let format = match formats.is_empty() { true => None, @@ -1090,8 +1092,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 @@ -1108,7 +1118,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, and how some of its // fields are shown. @@ -1440,6 +1455,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", @@ -1717,6 +1733,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()); @@ -2140,12 +2169,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, } } @@ -2164,6 +2194,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)); @@ -2171,6 +2203,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)] @@ -2216,6 +2298,7 @@ fn capabilities_read_back_from_their_names_and_nothing_else(tc: TestCase) { "tags:plan", "cues:plan", "artwork:plan", + "analysis:plan", "events:drives", "events:jobs", "events:plans", diff --git a/crates/loclib-plugin/src/sdk/tests.rs b/crates/loclib-plugin/src/sdk/tests.rs index 0745b7f0..6f59fb2b 100644 --- a/crates/loclib-plugin/src/sdk/tests.rs +++ b/crates/loclib-plugin/src/sdk/tests.rs @@ -99,6 +99,7 @@ fn hello_answers_who_the_plugin_is_and_ignores_unknown_features() { let plugin = PluginInfo { id: "echo".into(), version: "0.1.0".into() }; // And the SDK's own. let features = [ + "analysis", "commands", "operations", "panels", @@ -360,7 +361,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", "views"]); + assert_eq!(reply.features, ["analysis", "analyzers", "blobs", "net", "choose", "views"]); let track = json!({ "id": 1, "file_path": "/a.mp3", "audio_path": "/a.mp3" }); for (method, params) in [ (method::RUN_COMMAND, run("echo", json!({}), None)), @@ -376,7 +377,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", "views"], "only the SDK's own"); + assert_eq!(reply.features, ["analysis", "blobs", "net", "choose", "views"], "only the SDK's own"); none.stop(); s.stop(); } @@ -542,6 +543,7 @@ fn hello_comes_first_and_then_the_plugin_describes_its_manifest(tc: TestCase) { let plugin = PluginInfo { id: manifest.id.clone(), version: manifest.version.clone() }; // And the SDK's own. let features = [ + "analysis", "commands", "operations", "panels", diff --git a/crates/loclib-plugin/src/sdk/tests/events.rs b/crates/loclib-plugin/src/sdk/tests/events.rs index 65093749..576a3fa4 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", "views"]); + assert_eq!(reply.features, ["analysis", "events", "blobs", "net", "choose", "views"]); 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", "views"]); + assert_eq!(reply.features, ["analysis", "services", "blobs", "net", "choose", "views"]); 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();