From 1d459e24a40dbd5acac4f75ee42ebe5999280b32 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 4 Oct 2026 09:25:42 -0700 Subject: [PATCH] menuband: a collection .mbscore opens to its track list; the MacNeoPolitan Trio's album MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A score may carry tracks instead of voices — a list of sibling .mbscore files with cached title, section, members, bpm and length. Opening one in Menu Band shows the list (ScoreTrackList.swift); double-click, Return or Play performs the track as opening its file would, Stop halts it. QuickLook previews the list and the thumbnail is the album sleeve. trio.mjs lists a collection, conducts one track with --track N, or all of them with --setlist. scores/macneopolitan.mbscore holds all 34 Trio scores in five sections, written by bin/collection.mjs from the lane's setlist, songbook and dialogs. --- .../macneopolitan/bin/collection.mjs | 96 +++++ .../macneopolitan/bin/trio.mjs | 60 ++- .../scores/macneopolitan.mbscore | 290 ++++++++++++++ .../Sources/MenuBand/AppDelegate.swift | 16 +- .../Sources/MenuBand/ScoreTrackList.swift | 375 ++++++++++++++++++ .../Preview/PreviewViewController.swift | 74 +++- .../Thumbnail/ThumbnailProvider.swift | 53 +++ slab/menuband/scores/README.md | 34 ++ 8 files changed, 992 insertions(+), 6 deletions(-) create mode 100644 grants/culturehub-la-2026/macneopolitan/bin/collection.mjs create mode 100644 grants/culturehub-la-2026/macneopolitan/scores/macneopolitan.mbscore create mode 100644 slab/menuband/Sources/MenuBand/ScoreTrackList.swift diff --git a/grants/culturehub-la-2026/macneopolitan/bin/collection.mjs b/grants/culturehub-la-2026/macneopolitan/bin/collection.mjs new file mode 100644 index 0000000000..a04165d4a3 --- /dev/null +++ b/grants/culturehub-la-2026/macneopolitan/bin/collection.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +// collection.mjs — write scores/macneopolitan.mbscore, the Trio's album. +// +// node bin/collection.mjs # rewrite scores/macneopolitan.mbscore +// node bin/collection.mjs --print # show the track list, write nothing +// +// A collection is a `.mbscore` whose `tracks` name sibling `.mbscore` files +// instead of carrying `voices` (slab/menuband/scores/README.md → Collections). +// Opening it in Menu Band shows the list; `node bin/trio.mjs +// scores/macneopolitan.mbscore` prints it. Each entry carries the sibling's +// title, bpm, member count and length as hints, so a reader can draw the +// list without opening thirty files — the sibling stays the truth. +// +// Sections follow the lane's own lists: the four movements (setlist.json), +// the songbook (songbook.json), the dialogs (dialogs.json), then the fleet +// arrangements and the studies that fit nowhere else, in name order. +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { resolve, dirname, basename } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SCORES = resolve(HERE, "..", "scores"); +const OUT = resolve(SCORES, "macneopolitan.mbscore"); +const printOnly = process.argv.includes("--print"); + +const readJson = (p) => JSON.parse(readFileSync(p, "utf8")); +const beatsOf = (s) => String(s || "").split(",").reduce((a, t) => a + (parseFloat(t.split(":")[1]) || 0), 0); +const voiceBeats = (v) => Math.max(0, ...["notes", "notes2", "notes3", "notes4"].map((k) => beatsOf(v[k]))); + +// The lane's titles all wear the band's name; the album already says it. +function shortTitle(title, file) { + let t = String(title || basename(file, ".mbscore")); + t = t.replace(/^The MacNeoPolitan Trio\s+—\s+/, ""); + t = t.replace(/\s+—\s+MacNeoPolitan Trio$/, ""); + t = t.replace(/\s+—\s+MacNeoPolitan (.+)$/, " ($1)"); + return t; +} + +function entry(file, section) { + const score = readJson(resolve(SCORES, file)); + if (score.tracks) return null; // a collection is not a track + const voices = score.voices || []; + const bpm = score.bpm || 120; + const beats = Math.max(0, ...voices.map(voiceBeats)); + const e = { + file, + title: shortTitle(score.title, file), + section, + machines: score.machines ?? voices.length, + bpm, + seconds: Math.round(beats * 60 / bpm * 10) / 10, + }; + if (score.requiresFleet) e.requiresFleet = true; + if (score.phonemeOnly) e.phonemeOnly = true; + return e; +} + +const tracks = []; +const seen = new Set(); +const add = (file, section) => { + if (seen.has(file)) return; + const e = entry(file, section); + if (!e) return; + seen.add(file); + tracks.push(e); +}; + +for (const f of readJson(resolve(SCORES, "setlist.json")).movements) add(f, "Movements"); +for (const f of readJson(resolve(SCORES, "songbook.json")).movements) add(f, "Songbook"); +for (const f of readJson(resolve(SCORES, "dialogs.json")).movements) add(f, "Dialogs"); +const rest = readdirSync(SCORES).filter((f) => f.endsWith(".mbscore") && !seen.has(f) && f !== basename(OUT)).sort(); +for (const f of rest) if (readJson(resolve(SCORES, f)).requiresFleet) add(f, "Fleet arrangements"); +for (const f of rest) add(f, "Studies"); + +const total = tracks.reduce((a, t) => a + t.seconds, 0); +const collection = { + title: "The MacNeoPolitan Trio", + composer: "The machines — neo, blueberry and frisbee — with jeffrey", + machines: 3, + description: `Every score the Trio knows, in one album: the four movements, the songbook, the ten dialogs, the fleet arrangements and the studies. ${tracks.length} tracks, about ${Math.round(total / 60)} minutes. Open it in Menu Band to pick a track; node bin/trio.mjs scores/macneopolitan.mbscore lists them.`, + gap: 4, + tracks, +}; + +if (printOnly) { + let section = null; + for (const [i, t] of tracks.entries()) { + if (t.section !== section) { section = t.section; console.log(`\n${section}`); } + const m = Math.floor(t.seconds / 60), s = String(Math.round(t.seconds % 60)).padStart(2, "0"); + console.log(` ${String(i + 1).padStart(2)}. ${t.title.padEnd(34)} ${"●".repeat(t.machines)} ${String(t.bpm).padStart(3)} bpm ${m}:${s}${t.requiresFleet ? " fleet" : ""}`); + } + console.log(`\n${tracks.length} tracks · ${Math.round(total / 60)} min`); +} else { + writeFileSync(OUT, JSON.stringify(collection, null, 2) + "\n"); + console.log(`wrote ${OUT} — ${tracks.length} tracks, ${Math.round(total / 60)} min`); +} diff --git a/grants/culturehub-la-2026/macneopolitan/bin/trio.mjs b/grants/culturehub-la-2026/macneopolitan/bin/trio.mjs index bf4e1aceca..fc59e26068 100644 --- a/grants/culturehub-la-2026/macneopolitan/bin/trio.mjs +++ b/grants/culturehub-la-2026/macneopolitan/bin/trio.mjs @@ -4,6 +4,13 @@ // node bin/trio.mjs scores/trio-i-birth.mbscore neo blueberry blush // node bin/trio.mjs scores/trio-i-birth.mbscore neo blueberry --reduce // node bin/trio.mjs --setlist scores/setlist.json neo blueberry blush +// node bin/trio.mjs scores/macneopolitan.mbscore (list the album's tracks) +// node bin/trio.mjs scores/macneopolitan.mbscore --track 5 neo blueberry frisbee +// node bin/trio.mjs --setlist scores/macneopolitan.mbscore neo blueberry frisbee +// +// A collection (`tracks` naming sibling scores, no `voices` — the album +// bin/collection.mjs writes) lists its tracks when given alone; --track N +// conducts one of them, --setlist conducts them all in order. // // One host per voice, in order. A host is local when it matches this // machine's LocalHostName; otherwise it is reached over ssh. Everything @@ -273,8 +280,51 @@ const dry = flags.includes("--dry"); const show = flags.includes("--show"); // print every payload instead of posting it const setlistMode = flags.includes("--setlist"); -const [scoreArg, ...givenHosts] = words; +const trackFlag = flags.find((f) => /^--track(=|$)/.test(f)); +let [scoreArg, ...givenHosts] = words; +// `--track 5` may arrive as one flag (`--track=5`) or a flag and a word. +let trackPick = trackFlag ? trackFlag.split("=")[1] : null; +if (trackFlag && trackPick == null) { + const i = argv.indexOf(trackFlag); + trackPick = argv[i + 1]; + givenHosts = givenHosts.filter((h) => h !== trackPick); +} const hosts = sim ? [localName] : givenHosts; // the simulator plays the whole band here + +// A collection given alone prints its track list — the album's table of +// contents — and stops. With --track N it stands in for that track below. +const readCollection = (p) => { + const j = JSON.parse(readFileSync(p, "utf8")); + return j.tracks && !j.voices ? j : null; +}; +const clock = (sec) => `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, "0")}`; +if (scoreArg && existsSync(resolve(scoreArg))) { + const album = readCollection(resolve(scoreArg)); + if (album && !setlistMode) { + if (trackPick == null) { + console.log(`\n${album.title || basename(scoreArg)} — ${album.tracks.length} tracks\n`); + let section = null; + album.tracks.forEach((t, i) => { + if (t.section && t.section !== section) { section = t.section; console.log(` ${section}`); } + const dots = "●".repeat(t.machines ?? 0); + const len = t.seconds != null ? clock(t.seconds) : ""; + console.log(` ${String(i + 1).padStart(3)}. ${(t.title || t.file).padEnd(36)} ${dots.padEnd(4)} ${t.bpm ? String(t.bpm).padStart(3) + " bpm" : " "} ${len}${t.requiresFleet ? " fleet" : ""}`); + }); + const total = album.tracks.reduce((a, t) => a + (t.seconds || 0), 0); + console.log(`\n ${album.tracks.length} tracks · ${Math.round(total / 60)} min`); + console.log(` conduct one: node bin/trio.mjs ${scoreArg} --track N [host2 host3]`); + console.log(` conduct all: node bin/trio.mjs --setlist ${scoreArg} [host2 host3]\n`); + process.exit(0); + } + const n = Number(trackPick); + const entry = Number.isInteger(n) && n >= 1 && n <= album.tracks.length + ? album.tracks[n - 1] + : album.tracks.find((t) => t.file === trackPick || basename(t.file, ".mbscore") === trackPick); + if (!entry) { console.error(` ✗ no track ${trackPick} in ${scoreArg} (1–${album.tracks.length}, or a file name)`); process.exit(1); } + scoreArg = resolve(dirname(resolve(scoreArg)), entry.file); + console.log(`▶ ${album.title || basename(scoreArg)} · track ${album.tracks.indexOf(entry) + 1}: ${entry.title || entry.file}`); + } +} // "/" tokens in lyrics are caption line breaks (compose.mjs puts one at every // phrase); Menu Band reads them, the older paths (livesing, vox, stems) don't. const bareLyrics = (l) => String(l).trim().split(/\s+/).filter((t) => t !== "/").join(" "); @@ -282,6 +332,8 @@ if (!scoreArg || !hosts.length) { console.log("usage: node bin/trio.mjs [host2 host3] [--reduce] [--quiet] [--dry] [--corner]"); console.log(" node bin/trio.mjs --sim (the whole band on this machine)"); console.log(" node bin/trio.mjs --setlist [host2 host3] [--reduce]"); + console.log(" node bin/trio.mjs (list a collection's tracks)"); + console.log(" node bin/trio.mjs --track N [host2 host3]"); process.exit(1); } @@ -289,11 +341,13 @@ if (setlistMode) { // A setlist is { "movements": ["scores/a.mbscore", …], "gap": 4.0 }: // each movement is conducted in turn by a fresh trio.mjs, waiting for the // previous one to finish sounding plus `gap` seconds of hall. + // A collection .mbscore is a setlist too: its tracks' files, in order. const list = JSON.parse(readFileSync(resolve(scoreArg), "utf8")); + const movements = list.movements ?? (list.tracks || []).map((t) => t.file); const gap = list.gap ?? 4.0; const pass = flags.filter((f) => f !== "--setlist"); - console.log(`\n▶ setlist: ${list.title || basename(scoreArg)} — ${list.movements.length} movements\n`); - for (const mv of list.movements) { + console.log(`\n▶ setlist: ${list.title || basename(scoreArg)} — ${movements.length} movements\n`); + for (const mv of movements) { const mvPath = resolve(dirname(resolve(scoreArg)), mv); const r = spawnSync("node", [fileURLToPath(import.meta.url), mvPath, ...hosts, ...pass], { stdio: ["ignore", "pipe", "inherit"], encoding: "utf8" }); diff --git a/grants/culturehub-la-2026/macneopolitan/scores/macneopolitan.mbscore b/grants/culturehub-la-2026/macneopolitan/scores/macneopolitan.mbscore new file mode 100644 index 0000000000..68c9792d06 --- /dev/null +++ b/grants/culturehub-la-2026/macneopolitan/scores/macneopolitan.mbscore @@ -0,0 +1,290 @@ +{ + "title": "The MacNeoPolitan Trio", + "composer": "The machines — neo, blueberry and frisbee — with jeffrey", + "machines": 3, + "description": "Every score the Trio knows, in one album: the four movements, the songbook, the ten dialogs, the fleet arrangements and the studies. 34 tracks, about 41 minutes. Open it in Menu Band to pick a track; node bin/trio.mjs scores/macneopolitan.mbscore lists them.", + "gap": 4, + "tracks": [ + { + "file": "trio-i-birth.mbscore", + "title": "I. Birth", + "section": "Movements", + "machines": 3, + "bpm": 132, + "seconds": 83.2 + }, + { + "file": "trio-ii-service.mbscore", + "title": "II. Service", + "section": "Movements", + "machines": 3, + "bpm": 120, + "seconds": 53 + }, + { + "file": "trio-iii-chorus.mbscore", + "title": "III. Chorus", + "section": "Movements", + "machines": 3, + "bpm": 92, + "seconds": 62 + }, + { + "file": "trio-iv-ballad.mbscore", + "title": "IV. The Ballad of neo", + "section": "Movements", + "machines": 3, + "bpm": 100, + "seconds": 192 + }, + { + "file": "trio-wake.mbscore", + "title": "Good morning, Sophia", + "section": "Songbook", + "machines": 3, + "bpm": 69, + "seconds": 52.2 + }, + { + "file": "trio-lullaby.mbscore", + "title": "Lullaby for frisbee", + "section": "Songbook", + "machines": 3, + "bpm": 63, + "seconds": 48.6 + }, + { + "file": "trio-lights-out.mbscore", + "title": "Lights out", + "section": "Songbook", + "machines": 3, + "bpm": 64, + "seconds": 73.1 + }, + { + "file": "trio-open-me.mbscore", + "title": "Open me", + "section": "Songbook", + "machines": 3, + "bpm": 88, + "seconds": 76.4 + }, + { + "file": "trio-thirteen-days.mbscore", + "title": "Thirteen days", + "section": "Songbook", + "machines": 3, + "bpm": 70, + "seconds": 82.3 + }, + { + "file": "trio-the-record.mbscore", + "title": "The record", + "section": "Songbook", + "machines": 3, + "bpm": 104, + "seconds": 62.3 + }, + { + "file": "trio-vocalise.mbscore", + "title": "Vocalise", + "section": "Songbook", + "machines": 3, + "bpm": 90, + "seconds": 74.7, + "phonemeOnly": true + }, + { + "file": "trio-styling-study.mbscore", + "title": "Styling study", + "section": "Songbook", + "machines": 3, + "bpm": 96, + "seconds": 165, + "phonemeOnly": true + }, + { + "file": "trio-sums.mbscore", + "title": "Sums", + "section": "Songbook", + "machines": 3, + "bpm": 96, + "seconds": 82.5 + }, + { + "file": "trio-take-away.mbscore", + "title": "Take away", + "section": "Songbook", + "machines": 3, + "bpm": 92, + "seconds": 87.4 + }, + { + "file": "trio-spell-frisbee.mbscore", + "title": "How to spell frisbee", + "section": "Songbook", + "machines": 3, + "bpm": 96, + "seconds": 80 + }, + { + "file": "trio-spell-sophia.mbscore", + "title": "Spell Sophia", + "section": "Songbook", + "machines": 3, + "bpm": 92, + "seconds": 52.2 + }, + { + "file": "trio-abc.mbscore", + "title": "A B C", + "section": "Songbook", + "machines": 3, + "bpm": 100, + "seconds": 69.6 + }, + { + "file": "trio-what-starts-with.mbscore", + "title": "What starts with", + "section": "Songbook", + "machines": 3, + "bpm": 96, + "seconds": 77.5 + }, + { + "file": "dialog-01-radio.mbscore", + "title": "Dialog 1: Radio", + "section": "Dialogs", + "machines": 2, + "bpm": 100, + "seconds": 52.2 + }, + { + "file": "dialog-02-the-downbeat.mbscore", + "title": "Dialog 2: The downbeat", + "section": "Dialogs", + "machines": 2, + "bpm": 100, + "seconds": 66.6 + }, + { + "file": "dialog-03-wallpaper.mbscore", + "title": "Dialog 3: Wallpaper", + "section": "Dialogs", + "machines": 2, + "bpm": 100, + "seconds": 52.2 + }, + { + "file": "dialog-04-who-carries-what.mbscore", + "title": "Dialog 4: Who carries what", + "section": "Dialogs", + "machines": 2, + "bpm": 100, + "seconds": 55.8 + }, + { + "file": "dialog-05-sleep.mbscore", + "title": "Dialog 5: Sleep", + "section": "Dialogs", + "machines": 2, + "bpm": 100, + "seconds": 61.2 + }, + { + "file": "dialog-06-six-minutes.mbscore", + "title": "Dialog 6: Six minutes", + "section": "Dialogs", + "machines": 2, + "bpm": 100, + "seconds": 50.4 + }, + { + "file": "dialog-07-before.mbscore", + "title": "Dialog 7: Before", + "section": "Dialogs", + "machines": 2, + "bpm": 100, + "seconds": 73.8 + }, + { + "file": "dialog-08-typed-at.mbscore", + "title": "Dialog 8: Typed at", + "section": "Dialogs", + "machines": 2, + "bpm": 100, + "seconds": 75.6 + }, + { + "file": "dialog-09-the-squad.mbscore", + "title": "Dialog 9: The squad", + "section": "Dialogs", + "machines": 2, + "bpm": 100, + "seconds": 129.6 + }, + { + "file": "dialog-10-your-first-day.mbscore", + "title": "Dialog 10: Your first day", + "section": "Dialogs", + "machines": 2, + "bpm": 100, + "seconds": 133.2 + }, + { + "file": "trio-chorus-doowop.mbscore", + "title": "Doo-wop", + "section": "Fleet arrangements", + "machines": 3, + "bpm": 88, + "seconds": 43.6, + "requiresFleet": true, + "phonemeOnly": true + }, + { + "file": "trio-one-big-voice.mbscore", + "title": "One Big Voice", + "section": "Fleet arrangements", + "machines": 3, + "bpm": 128, + "seconds": 60, + "requiresFleet": true + }, + { + "file": "trio-rowboat.mbscore", + "title": "Row, Row, Row Your Boat", + "section": "Fleet arrangements", + "machines": 3, + "bpm": 96, + "seconds": 30, + "requiresFleet": true + }, + { + "file": "trio-toxic-sketch.mbscore", + "title": "Toxic (wordless sketch)", + "section": "Fleet arrangements", + "machines": 3, + "bpm": 144, + "seconds": 30, + "requiresFleet": true, + "phonemeOnly": true + }, + { + "file": "mary-had-a-little-lamb.mbscore", + "title": "Mary Had a Little Lamb — first singing test", + "section": "Studies", + "machines": 1, + "bpm": 104, + "seconds": 18.5 + }, + { + "file": "trio-chorus-phonemes.mbscore", + "title": "Phoneme study", + "section": "Studies", + "machines": 3, + "bpm": 96, + "seconds": 40, + "phonemeOnly": true + } + ] +} diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 305da566eb..39c017939a 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -4873,13 +4873,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// double-clicked on becomes the host. Reads the JSON and fires every voice /// at one shared start instant so the whole arrangement renders locally /// (in sync, robot badge lit). No conductor, no ssh — just open the file. + /// A collection (`tracks` naming sibling scores, no `voices`) opens to its + /// track list instead; picking a track comes back through here. func playScoreFile(_ url: URL) { guard let data = try? Data(contentsOf: url), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let voices = obj["voices"] as? [[String: Any]] else { + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { NSLog("🎼 mbscore: could not read \(url.lastPathComponent)") return } + if obj["voices"] == nil, let tracks = obj["tracks"] as? [[String: Any]] { + NSLog("🎼 mbscore collection: \(obj["title"] as? String ?? url.lastPathComponent) — \(tracks.count) track(s)") + ScoreTrackListController.present(collection: url, obj: obj, + play: { [weak self] track in self?.playScoreFile(track) }, + stop: { [weak self] in self?.stopScore(broadcast: false) }) + return + } + guard let voices = obj["voices"] as? [[String: Any]] else { + NSLog("🎼 mbscore: \(url.lastPathComponent) carries neither voices nor tracks") + return + } let bpm = obj["bpm"].map { "\($0)" } ?? "120" let lead = (obj["lead"] as? Double) ?? 3.0 let epoch = String(format: "%.3f", Date().timeIntervalSince1970 + max(0.3, lead)) diff --git a/slab/menuband/Sources/MenuBand/ScoreTrackList.swift b/slab/menuband/Sources/MenuBand/ScoreTrackList.swift new file mode 100644 index 0000000000..22941e3f93 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/ScoreTrackList.swift @@ -0,0 +1,375 @@ +import AppKit + +/// The track list a collection `.mbscore` opens to. +/// +/// A collection is a score whose `tracks` name sibling `.mbscore` files +/// instead of carrying `voices` (scores/README.md → "Collections"). Opening +/// one in Finder shows every track in its sections; double-click, Return or +/// Play performs the chosen track exactly as opening that file would, and +/// Stop halts whatever is sounding. A track may itself be a collection — it +/// opens to its own list. +/// +/// Each entry may carry `title` / `machines` / `bpm` / `seconds` as hints so +/// the list draws without reading thirty files; a missing hint is read from +/// the sibling itself. +final class ScoreTrackListController: NSWindowController, NSWindowDelegate, + NSTableViewDataSource, NSTableViewDelegate { + struct Track { + let url: URL + let title: String + let machines: Int + let bpm: Int + let seconds: Double + let fleet: Bool + let isCollection: Bool + } + + private enum Row { + case section(String) + case track(Track, number: Int) + } + + /// One window per collection file; opening the same file again just + /// brings its list forward. Cleared in `windowWillClose`. + private static var active: [URL: ScoreTrackListController] = [:] + + private let rows: [Row] + private let trackCount: Int + private let play: (URL) -> Void + private let stop: () -> Void + private var tableView: NSTableView! + private var playButton: NSButton! + + // MARK: - Opening + + /// Show (or raise) the list for a collection already parsed by the caller. + static func present(collection url: URL, obj: [String: Any], + play: @escaping (URL) -> Void, stop: @escaping () -> Void) { + let key = url.standardizedFileURL + if let open = active[key] { open.raise(); return } + let controller = ScoreTrackListController(url: key, obj: obj, play: play, stop: stop) + active[key] = controller + controller.raise() + } + + private init(url: URL, obj: [String: Any], + play: @escaping (URL) -> Void, stop: @escaping () -> Void) { + self.play = play + self.stop = stop + let tracks = Self.tracks(of: obj, beside: url) + var rows: [Row] = [] + var section: String? + for (i, (track, sec)) in tracks.enumerated() { + if let sec, sec != section { rows.append(.section(sec)); section = sec } + rows.append(.track(track, number: i + 1)) + } + self.rows = rows + self.trackCount = tracks.count + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 560, height: 520), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = (obj["title"] as? String) ?? url.deletingPathExtension().lastPathComponent + window.isReleasedWhenClosed = false + window.level = .normal + window.minSize = NSSize(width: 420, height: 260) + window.setFrameAutosaveName("ScoreTrackList:\(url.lastPathComponent)") + super.init(window: window) + window.delegate = self + buildContent(obj: obj, total: tracks.reduce(0) { $0 + $1.0.seconds }) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not used") } + + private func raise() { + guard let window else { return } + if !window.isVisible { window.center() } + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + if tableView.selectedRow < 0, let first = rows.firstIndex(where: { if case .track = $0 { return true } else { return false } }) { + tableView.selectRowIndexes(IndexSet(integer: first), byExtendingSelection: false) + } + window.makeFirstResponder(tableView) + } + + func windowWillClose(_ notification: Notification) { + Self.active = Self.active.filter { $0.value !== self } + } + + // MARK: - Reading the entries + + /// Every track with its section label, in file order. Entries without a + /// `file` are skipped; hints missing from an entry are read from the sibling. + private static func tracks(of obj: [String: Any], beside url: URL) -> [(Track, String?)] { + let dir = url.deletingLastPathComponent() + var out: [(Track, String?)] = [] + for entry in (obj["tracks"] as? [[String: Any]]) ?? [] { + guard let file = entry["file"] as? String, !file.isEmpty else { continue } + let trackURL = file.hasPrefix("/") ? URL(fileURLWithPath: file) + : dir.appendingPathComponent(file) + let hinted = entry["title"] != nil && entry["machines"] != nil + && entry["bpm"] != nil && entry["seconds"] != nil + let sibling: [String: Any] = hinted ? [:] : (read(trackURL) ?? [:]) + let voices = (sibling["voices"] as? [[String: Any]]) ?? [] + let bpm = intValue(entry["bpm"]) ?? intValue(sibling["bpm"]) ?? 120 + let title = (entry["title"] as? String) ?? (sibling["title"] as? String) + ?? trackURL.deletingPathExtension().lastPathComponent + let machines = intValue(entry["machines"]) ?? intValue(sibling["machines"]) ?? max(1, voices.count) + let seconds = doubleValue(entry["seconds"]) ?? (longestBeats(voices) * 60.0 / Double(max(1, bpm))) + let fleet = (entry["requiresFleet"] as? Bool) ?? (sibling["requiresFleet"] as? Bool) ?? false + let isCollection = sibling["tracks"] != nil + out.append((Track(url: trackURL, title: title, machines: machines, bpm: bpm, + seconds: seconds, fleet: fleet, isCollection: isCollection), + entry["section"] as? String)) + } + return out + } + + private static func read(_ url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url) else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + + private static func intValue(_ v: Any?) -> Int? { + if let i = v as? Int { return i } + if let d = v as? Double { return Int(d) } + return nil + } + + private static func doubleValue(_ v: Any?) -> Double? { + if let d = v as? Double { return d } + if let i = v as? Int { return Double(i) } + return nil + } + + /// Beats of the longest `notes`/`notes2`/… track across the voices — the + /// same measure the host-play recorder and the QuickLook preview use. + private static func longestBeats(_ voices: [[String: Any]]) -> Double { + voices.flatMap { voice in + ["notes", "notes2", "notes3", "notes4"].compactMap { voice[$0] as? String } + }.map { spec in + spec.split(separator: ",").reduce(0.0) { total, token in + let parts = token.split(separator: ":") + return total + (parts.count == 2 ? (Double(parts[1]) ?? 0) : 0) + } + }.max() ?? 0 + } + + private static func clock(_ seconds: Double) -> String { + let s = Int(seconds.rounded()) + return String(format: "%d:%02d", s / 60, s % 60) + } + + // MARK: - Layout + + private func buildContent(obj: [String: Any], total: Double) { + guard let window else { return } + let content = NSView() + window.contentView = content + + let header = NSTextField(wrappingLabelWithString: Self.headerText(obj, count: trackCount, total: total)) + header.translatesAutoresizingMaskIntoConstraints = false + header.font = NSFont.systemFont(ofSize: 12) + header.textColor = .secondaryLabelColor + header.maximumNumberOfLines = 3 + header.lineBreakMode = .byTruncatingTail + content.addSubview(header) + + let scroll = NSScrollView() + scroll.translatesAutoresizingMaskIntoConstraints = false + scroll.hasVerticalScroller = true + scroll.borderType = .bezelBorder + content.addSubview(scroll) + + let table = NSTableView() + table.usesAlternatingRowBackgroundColors = true + table.headerView = NSTableHeaderView() + table.allowsMultipleSelection = false + table.style = .inset + table.rowHeight = 22 + table.floatsGroupRows = true + table.dataSource = self + table.delegate = self + table.target = self + table.doubleAction = #selector(playSelected) + + let columns: [(String, String, CGFloat, NSTextAlignment)] = [ + ("number", "#", 32, .right), + ("title", "Track", 280, .left), + ("members", "Members", 70, .left), + ("bpm", "bpm", 44, .right), + ("length", "Length", 56, .right), + ] + for (id, title, width, _) in columns { + let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(id)) + col.title = title + col.width = width + col.minWidth = id == "title" ? 140 : width + col.resizingMask = id == "title" ? .autoresizingMask : [] + table.addTableColumn(col) + } + table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle + scroll.documentView = table + tableView = table + + let playButton = NSButton(title: "Play", target: self, action: #selector(playSelected)) + playButton.bezelStyle = .rounded + playButton.keyEquivalent = "\r" + playButton.translatesAutoresizingMaskIntoConstraints = false + self.playButton = playButton + + let stopButton = NSButton(title: "Stop", target: self, action: #selector(stopPlaying)) + stopButton.bezelStyle = .rounded + stopButton.keyEquivalent = "." + stopButton.keyEquivalentModifierMask = [.command] + stopButton.translatesAutoresizingMaskIntoConstraints = false + + let footer = NSTextField(labelWithString: "\(trackCount) tracks · \(Self.clock(total))") + footer.translatesAutoresizingMaskIntoConstraints = false + footer.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) + footer.textColor = .secondaryLabelColor + + content.addSubview(playButton) + content.addSubview(stopButton) + content.addSubview(footer) + + NSLayoutConstraint.activate([ + header.topAnchor.constraint(equalTo: content.topAnchor, constant: 12), + header.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), + header.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16), + + scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 10), + scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 12), + scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12), + scroll.bottomAnchor.constraint(equalTo: playButton.topAnchor, constant: -12), + + footer.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), + footer.centerYAnchor.constraint(equalTo: playButton.centerYAnchor), + + playButton.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12), + playButton.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -12), + playButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 72), + stopButton.trailingAnchor.constraint(equalTo: playButton.leadingAnchor, constant: -8), + stopButton.centerYAnchor.constraint(equalTo: playButton.centerYAnchor), + stopButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 72), + ]) + updatePlayButton() + } + + private static func headerText(_ obj: [String: Any], count: Int, total: Double) -> String { + var parts: [String] = [] + if let composer = obj["composer"] as? String, !composer.isEmpty { parts.append(composer) } + if let description = obj["description"] as? String, !description.isEmpty { parts.append(description) } + if parts.isEmpty { parts.append("\(count) tracks · \(clock(total))") } + return parts.joined(separator: "\n") + } + + // MARK: - Actions + + private var selectedTrack: Track? { + let row = tableView.selectedRow + guard row >= 0, row < rows.count, case .track(let t, _) = rows[row] else { return nil } + return t + } + + @objc private func playSelected() { + guard let track = selectedTrack else { return } + guard FileManager.default.fileExists(atPath: track.url.path) else { + NSLog("🎼 mbscore collection: missing track \(track.url.lastPathComponent)") + NSSound.beep() + return + } + NSLog("🎼 mbscore collection: ▶ \(track.title)") + play(track.url) + } + + @objc private func stopPlaying() { stop() } + + private func updatePlayButton() { + let t = selectedTrack + playButton.isEnabled = t != nil + playButton.title = (t?.isCollection ?? false) ? "Open" : "Play" + } + + // MARK: - NSTableViewDataSource / NSTableViewDelegate + + func numberOfRows(in tableView: NSTableView) -> Int { rows.count } + + func tableView(_ tableView: NSTableView, isGroupRow row: Int) -> Bool { + if case .section = rows[row] { return true } + return false + } + + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + if case .track = rows[row] { return true } + return false + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + if case .section = rows[row] { return 24 } + return 22 + } + + func tableViewSelectionDidChange(_ notification: Notification) { updatePlayButton() } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + switch rows[row] { + case .section(let name): + let id = NSUserInterfaceItemIdentifier("section") + let cell = tableView.makeView(withIdentifier: id, owner: self) as? NSTableCellView ?? makeCell(id, alignment: .left) + cell.textField?.stringValue = name + cell.textField?.font = NSFont.systemFont(ofSize: 11, weight: .semibold) + cell.textField?.textColor = .secondaryLabelColor + return cell + case .track(let t, let number): + guard let column = tableColumn else { return nil } + let key = column.identifier.rawValue + let id = NSUserInterfaceItemIdentifier("cell-\(key)") + let alignment: NSTextAlignment = (key == "number" || key == "bpm" || key == "length") ? .right : .left + let cell = tableView.makeView(withIdentifier: id, owner: self) as? NSTableCellView ?? makeCell(id, alignment: alignment) + cell.textField?.textColor = .labelColor + switch key { + case "number": + cell.textField?.stringValue = "\(number)" + cell.textField?.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) + cell.textField?.textColor = .secondaryLabelColor + case "title": + cell.textField?.stringValue = t.isCollection ? "\(t.title) ▸" : t.title + cell.textField?.font = NSFont.systemFont(ofSize: 12) + case "members": + cell.textField?.stringValue = String(repeating: "●", count: max(1, t.machines)) + (t.fleet ? " fleet" : "") + cell.textField?.font = NSFont.systemFont(ofSize: 10) + cell.textField?.textColor = .secondaryLabelColor + case "bpm": + cell.textField?.stringValue = "\(t.bpm)" + cell.textField?.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) + case "length": + cell.textField?.stringValue = Self.clock(t.seconds) + cell.textField?.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) + default: + cell.textField?.stringValue = "" + } + return cell + } + } + + private func makeCell(_ id: NSUserInterfaceItemIdentifier, alignment: NSTextAlignment) -> NSTableCellView { + let cell = NSTableCellView() + cell.identifier = id + let tf = NSTextField(labelWithString: "") + tf.translatesAutoresizingMaskIntoConstraints = false + tf.lineBreakMode = .byTruncatingTail + tf.alignment = alignment + cell.addSubview(tf) + cell.textField = tf + NSLayoutConstraint.activate([ + tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4), + tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4), + tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } +} diff --git a/slab/menuband/quicklook/Preview/PreviewViewController.swift b/slab/menuband/quicklook/Preview/PreviewViewController.swift index fd6a616e7b..919db5c342 100644 --- a/slab/menuband/quicklook/Preview/PreviewViewController.swift +++ b/slab/menuband/quicklook/Preview/PreviewViewController.swift @@ -1,7 +1,8 @@ // QuickLook preview for .mbscore: a live piano-roll "graphic score" with a // playhead that sweeps in real time (looping over the score's duration), notes // lighting up as it passes and drum hits flashing — so ⌘-Space shows the score -// *playing*, not raw JSON. +// *playing*, not raw JSON. A collection (`tracks` naming sibling scores, no +// `voices`) previews as its track list, in sections. import Cocoa import Quartz @@ -29,8 +30,11 @@ final class ScoreRollView: NSView { private struct Note { let start, dur: Double; let midi, voice, track: Int } private struct Hit { let start: Double; let lane, voice: Int } + private struct Track { let title: String; let section: String?; let machines: Int; let seconds: Double } + private var notes: [Note] = [] private var hits: [Hit] = [] + private var tracks: [Track] = [] // non-empty = this is a collection private var total = 1.0 // beats private var seconds = 8.0 // wall duration of one pass private var title = "" @@ -51,6 +55,20 @@ final class ScoreRollView: NSView { func load(_ obj: [String: Any]) { title = (obj["title"] as? String) ?? "Menu Band Score" bpm = (obj["bpm"] as? Int) ?? Int((obj["bpm"] as? Double) ?? 120) + if obj["voices"] == nil, let list = obj["tracks"] as? [[String: Any]] { + tracks = list.compactMap { e in + guard let file = e["file"] as? String else { return nil } + let title = (e["title"] as? String) ?? (file as NSString).deletingPathExtension + let machines = (e["machines"] as? Int) ?? Int((e["machines"] as? Double) ?? 1) + let seconds = (e["seconds"] as? Double) ?? Double((e["seconds"] as? Int) ?? 0) + return Track(title: title, section: e["section"] as? String, machines: machines, seconds: seconds) + } + machines = (obj["machines"] as? Int) ?? (tracks.map { $0.machines }.max() ?? 1) + timer?.invalidate(); timer = nil + needsDisplay = true + return + } + tracks = [] let voices = (obj["voices"] as? [[String: Any]]) ?? [] machines = (obj["machines"] as? Int) ?? max(1, voices.count) notes.removeAll(); hits.removeAll(); total = 1 @@ -86,6 +104,7 @@ final class ScoreRollView: NSView { let S = bounds NSColor(srgbRed: 0.06, green: 0.07, blue: 0.10, alpha: 1).setFill() S.fill() + if !tracks.isEmpty { drawTrackList(S); return } let drumStrip: CGFloat = hits.isEmpty ? 0 : 64 let plot = NSRect(x: 40, y: 70 + drumStrip, width: S.width - 80, height: S.height - 70 - drumStrip - 74) @@ -148,4 +167,57 @@ final class ScoreRollView: NSView { .foregroundColor: NSColor(white: 1, alpha: 0.5), .paragraphStyle: ps, ]).draw(in: NSRect(x: 30, y: 24, width: S.width - 60, height: 22)) } + + /// The collection view: title, then every track in its section, flowing + /// into as many columns as the height asks for. Nothing animates — a list + /// is read, not played. + private func drawTrackList(_ S: NSRect) { + let white = NSColor.white + let dim = NSColor(white: 1, alpha: 0.5) + let total = tracks.reduce(0.0) { $0 + $1.seconds } + let ps = NSMutableParagraphStyle(); ps.alignment = .center; ps.lineBreakMode = .byTruncatingTail + NSAttributedString(string: title, attributes: [ + .font: NSFont.systemFont(ofSize: 26, weight: .bold), .foregroundColor: white, .paragraphStyle: ps, + ]).draw(in: NSRect(x: 30, y: S.height - 58, width: S.width - 60, height: 34)) + let dots = String(repeating: "●", count: max(1, machines)) + let mins = Int((total / 60).rounded()) + NSAttributedString(string: "\(dots) \(tracks.count) tracks · \(mins) min", attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 14, weight: .medium), .foregroundColor: dim, .paragraphStyle: ps, + ]).draw(in: NSRect(x: 30, y: 24, width: S.width - 60, height: 22)) + + // Rows: a section header wherever the section changes, then its tracks. + var lines: [(text: String, header: Bool, voice: Int, clock: String)] = [] + var section: String? + for (i, t) in tracks.enumerated() { + if let sec = t.section, sec != section { lines.append((sec, true, 0, "")); section = sec } + let s = Int(t.seconds.rounded()) + lines.append(("\(i + 1). \(t.title)", false, i % palette.count, s > 0 ? String(format: "%d:%02d", s / 60, s % 60) : "")) + } + let area = NSRect(x: 40, y: 60, width: S.width - 80, height: S.height - 60 - 76) + let rowH: CGFloat = 20 + let perColumn = max(1, Int(area.height / rowH)) + let columns = max(1, Int(ceil(Double(lines.count) / Double(perColumn)))) + let colW = area.width / CGFloat(columns) + let left = NSMutableParagraphStyle(); left.lineBreakMode = .byTruncatingTail + let right = NSMutableParagraphStyle(); right.alignment = .right + for (n, line) in lines.enumerated() { + let col = n / perColumn, row = n % perColumn + let x = area.minX + CGFloat(col) * colW + let y = area.maxY - CGFloat(row + 1) * rowH + if line.header { + NSAttributedString(string: line.text.uppercased(), attributes: [ + .font: NSFont.systemFont(ofSize: 11, weight: .semibold), .foregroundColor: dim, .paragraphStyle: left, + ]).draw(in: NSRect(x: x + 14, y: y + 2, width: colW - 28, height: rowH - 4)) + continue + } + palette[line.voice].setFill() + NSBezierPath(ovalIn: NSRect(x: x + 2, y: y + rowH / 2 - 3, width: 6, height: 6)).fill() + NSAttributedString(string: line.text, attributes: [ + .font: NSFont.systemFont(ofSize: 13), .foregroundColor: white, .paragraphStyle: left, + ]).draw(in: NSRect(x: x + 14, y: y + 2, width: colW - 72, height: rowH - 4)) + NSAttributedString(string: line.clock, attributes: [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular), .foregroundColor: dim, .paragraphStyle: right, + ]).draw(in: NSRect(x: x + colW - 60, y: y + 2, width: 48, height: rowH - 4)) + } + } } diff --git a/slab/menuband/quicklook/Thumbnail/ThumbnailProvider.swift b/slab/menuband/quicklook/Thumbnail/ThumbnailProvider.swift index b5666c214d..f5c9a8f09d 100644 --- a/slab/menuband/quicklook/Thumbnail/ThumbnailProvider.swift +++ b/slab/menuband/quicklook/Thumbnail/ThumbnailProvider.swift @@ -37,6 +37,9 @@ enum ScoreThumb { struct Note { let start, dur: Double; let midi, voice: Int } struct Hit { let start: Double; let lane, voice: Int } let title = (obj["title"] as? String) ?? "Menu Band Score" + if obj["voices"] == nil, let tracks = obj["tracks"] as? [[String: Any]] { + drawCollection(title: title, tracks: tracks, size: size); return + } let voices = (obj["voices"] as? [[String: Any]]) ?? [] var notes: [Note] = []; var hits: [Hit] = []; var total = 1.0 for (vi, voice) in voices.enumerated() { @@ -98,4 +101,54 @@ enum ScoreThumb { .paragraphStyle: ps, ]).draw(in: NSRect(x: W * 0.08, y: H - H * 0.15, width: W * 0.84, height: H * 0.10)) } + + /// A collection's card: the title over the first few track names, with a + /// count for the rest — an album sleeve, where a score gets a piano roll. + private static func drawCollection(title: String, tracks: [[String: Any]], size: CGSize) { + let isDark = NSAppearance.currentDrawing().bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let W = size.width, H = size.height + let inset = W * 0.035 + let card = NSBezierPath(roundedRect: NSRect(x: inset, y: inset, width: W - inset * 2, height: H - inset * 2), + xRadius: W * 0.14, yRadius: W * 0.14) + (isDark ? NSColor(srgbRed: 0.10, green: 0.11, blue: 0.15, alpha: 1) + : NSColor(srgbRed: 0.97, green: 0.97, blue: 0.98, alpha: 1)).setFill() + card.fill() + card.setClip() + let ink = isDark ? NSColor.white : NSColor(white: 0.12, alpha: 1) + let names = tracks.compactMap { e -> String? in + (e["title"] as? String) ?? ((e["file"] as? String) as NSString?)?.deletingPathExtension + } + let rowH = H * 0.082 + let listTop = H - H * 0.30 + let listBottom = H * 0.12 + let fit = max(1, Int((listTop - listBottom) / rowH)) + let shown = names.count > fit ? Array(names.prefix(fit - 1)) : names + let ps = NSMutableParagraphStyle(); ps.lineBreakMode = .byTruncatingTail + for (i, name) in shown.enumerated() { + let y = listTop - CGFloat(i + 1) * rowH + palette[i % palette.count].setFill() + let d = rowH * 0.32 + NSBezierPath(ovalIn: NSRect(x: W * 0.12, y: y + (rowH - d) / 2, width: d, height: d)).fill() + NSAttributedString(string: name, attributes: [ + .font: NSFont.systemFont(ofSize: rowH * 0.62, weight: .medium), + .foregroundColor: ink.withAlphaComponent(0.9), .paragraphStyle: ps, + ]).draw(in: NSRect(x: W * 0.12 + d + W * 0.03, y: y + rowH * 0.12, width: W * 0.72 - d, height: rowH * 0.8)) + } + if names.count > shown.count { + let y = listTop - CGFloat(shown.count + 1) * rowH + NSAttributedString(string: "+ \(names.count - shown.count) more", attributes: [ + .font: NSFont.systemFont(ofSize: rowH * 0.55, weight: .regular), + .foregroundColor: ink.withAlphaComponent(0.5), .paragraphStyle: ps, + ]).draw(in: NSRect(x: W * 0.12 + rowH * 0.32 + W * 0.03, y: y + rowH * 0.12, width: W * 0.7, height: rowH * 0.8)) + } + let tps = NSMutableParagraphStyle(); tps.alignment = .center; tps.lineBreakMode = .byTruncatingTail + NSAttributedString(string: title, attributes: [ + .font: NSFont.systemFont(ofSize: H * 0.075, weight: .bold), + .foregroundColor: ink, .paragraphStyle: tps, + ]).draw(in: NSRect(x: W * 0.08, y: H - H * 0.15, width: W * 0.84, height: H * 0.10)) + NSAttributedString(string: "\(names.count) tracks", attributes: [ + .font: NSFont.monospacedDigitSystemFont(ofSize: H * 0.05, weight: .medium), + .foregroundColor: ink.withAlphaComponent(0.5), .paragraphStyle: tps, + ]).draw(in: NSRect(x: W * 0.08, y: H * 0.05, width: W * 0.84, height: H * 0.07)) + } } diff --git a/slab/menuband/scores/README.md b/slab/menuband/scores/README.md index 8b8496fbbe..d236cae6cd 100644 --- a/slab/menuband/scores/README.md +++ b/slab/menuband/scores/README.md @@ -52,6 +52,40 @@ comma-separated `token:beats`, where token is a MIDI note number (60 = middle C) or a drum letter (`k s h ho c rd cr`), and `r` is a rest — e.g. `60:0.25,r:0.5,67:1`. +## Collections (an album in one file) + +A `.mbscore` may hold **tracks instead of voices**: a list of sibling scores. +Opening it in Menu Band shows the track list — sections, member dots, bpm, +length — and a double-click (or Return / Play) performs that track exactly as +opening its own file would. Stop halts whatever is sounding. ⌘-Space previews +the list; the Finder thumbnail is the album sleeve. + +```jsonc +{ + "title": "The MacNeoPolitan Trio", + "composer": "The machines — neo, blueberry and frisbee — with jeffrey", + "machines": 3, + "description": "…", + "gap": 4, // seconds of hall between tracks when played in order + "tracks": [ + { "file": "trio-i-birth.mbscore", // relative to this file (or absolute) + "title": "I. Birth", // hints: title, section, machines, bpm, seconds, + "section": "Movements", // requiresFleet — cached from the sibling so a + "machines": 3, "bpm": 132, "seconds": 83.2 } // reader can list without opening it + ] +} +``` + +A collection has `tracks` and no `voices`; that pair is the whole signal. +`file` is the only required key per track — a reader fills missing hints from +the sibling (and the sibling stays the truth). A track may itself be a +collection; it opens to its own list. + +The MacNeoPolitan Trio's album is +`grants/culturehub-la-2026/macneopolitan/scores/macneopolitan.mbscore`, written +by `bin/collection.mjs` there; `node bin/trio.mjs scores/macneopolitan.mbscore` +lists it, `--track N` conducts one track and `--setlist` conducts them all. + ## Composing for more machines Set `machines` to 3+ and add that many voices (SATB choir, a rhythm section, a -- 2.51.2