@hraness/slopcamera 3.8.1 → 3.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,489 @@
1
+ import { beatGrid, importMidi } from "@hraness/soundfish/midi"
2
+ import { compose, verify } from "@hraness/soundfish/protocol"
3
+ import { applyTemplate } from "@hraness/soundfish/templates"
4
+ import {
5
+ BoundedFileError,
6
+ decodeUtf8Source,
7
+ hasPathCollision,
8
+ publishReplaceableFile,
9
+ readBoundedFile,
10
+ sha256Hex,
11
+ } from "./bounded-file.js"
12
+
13
+ /**
14
+ * Soundtrack scores and beat grids behind the `slopcamera.soundtrack.compose`
15
+ * and `slopcamera.soundtrack.grid` operations.
16
+ *
17
+ * Parsing, verification, MIDI import and beat-grid derivation come from the
18
+ * exact-version `@hraness/soundfish` library, called in process. Nothing is
19
+ * spawned, fetched, rendered to audio or evaluated. Slopcamera owns the
20
+ * operation codes, bounds, file publication and receipts, and projects the
21
+ * grid into the `{ bpm, beatOffsetUs, beatsPerBar }` music timing that HTML
22
+ * scene requests already accept.
23
+ */
24
+
25
+ /** The exact admitted library release. A test pins this to the lockfile. */
26
+ export const slopcameraSoundtrackEngine = Object.freeze({
27
+ package: "@hraness/soundfish",
28
+ version: "0.7.0",
29
+ } as const)
30
+
31
+ export const slopcameraSoundtrackLimits = Object.freeze({
32
+ sourceBytes: 1024 * 1024,
33
+ outputBytes: 4 * 1024 * 1024,
34
+ sections: 512,
35
+ warnings: 64,
36
+ /** Matches the HTML scene music clock's time and tempo bounds. */
37
+ maxTimeUs: 3_600_000_000,
38
+ minBpm: 20,
39
+ maxBpm: 400,
40
+ minBeatsPerBar: 1,
41
+ maxBeatsPerBar: 32,
42
+ })
43
+
44
+ export const slopcameraSoundtrackFormats = Object.freeze(
45
+ ["compose", "song", "json", "midi"] as const,
46
+ )
47
+ export type SlopcameraSoundtrackFormat = (typeof slopcameraSoundtrackFormats)[number]
48
+
49
+ export class SlopcameraSoundtrackError extends Error {
50
+ readonly code: "INVALID_SOUNDTRACK_SOURCE" | "INVALID_SOUNDTRACK_INPUT" | "SOUNDTRACK_OUT_OF_RANGE"
51
+
52
+ constructor(code: SlopcameraSoundtrackError["code"], message: string) {
53
+ super(message)
54
+ this.name = "SlopcameraSoundtrackError"
55
+ this.code = code
56
+ }
57
+ }
58
+
59
+ export interface SlopcameraSoundtrackFileRecord {
60
+ readonly path: string
61
+ readonly sha256: string
62
+ readonly bytes: number
63
+ }
64
+
65
+ export interface SlopcameraSoundtrackSourceRecord extends SlopcameraSoundtrackFileRecord {
66
+ readonly format: SlopcameraSoundtrackFormat
67
+ }
68
+
69
+ export interface SlopcameraSoundtrackWarning {
70
+ readonly code: string
71
+ readonly message: string
72
+ }
73
+
74
+ /** Exactly the explicit music timing HTML scene requests accept. */
75
+ export interface SlopcameraSoundtrackMusicTiming {
76
+ readonly bpm: number
77
+ readonly beatOffsetUs: number
78
+ readonly beatsPerBar: number
79
+ }
80
+
81
+ export interface SlopcameraSoundtrackCue {
82
+ /** 1-based play order; a repeated section appears once per repeat. */
83
+ readonly index: number
84
+ readonly label: string
85
+ /** 1-based repeat of that section. */
86
+ readonly repeat: number
87
+ readonly startBar: number
88
+ readonly endBar: number
89
+ readonly startBeat: number
90
+ readonly endBeat: number
91
+ readonly startUs: number
92
+ readonly endUs: number
93
+ }
94
+
95
+ export interface SlopcameraSoundtrackComposeInput {
96
+ readonly sourcePath: string
97
+ readonly outputPath?: string
98
+ readonly format?: SlopcameraSoundtrackFormat
99
+ }
100
+
101
+ export interface SlopcameraSoundtrackComposeReceipt {
102
+ readonly receiptVersion: 1
103
+ readonly operation: "slopcamera.soundtrack.compose"
104
+ readonly engine: typeof slopcameraSoundtrackEngine
105
+ readonly source: SlopcameraSoundtrackSourceRecord
106
+ readonly kind: "loop" | "song"
107
+ readonly title: string
108
+ /** The library's canonical document digest. */
109
+ readonly digest: string
110
+ /** The loop's music CID or the song's arrangement CID. */
111
+ readonly contentId: string
112
+ readonly bpm: number
113
+ readonly beatsPerBar: number
114
+ readonly bars: number
115
+ readonly durationUs: number
116
+ /** Loop tracks, or null for a song. */
117
+ readonly tracks: number | null
118
+ /** Song sections and distinct loops, or null for a loop. */
119
+ readonly sections: number | null
120
+ readonly loops: number | null
121
+ readonly warnings: readonly SlopcameraSoundtrackWarning[]
122
+ /** The canonical score document, written only when an output path was supplied. */
123
+ readonly output: SlopcameraSoundtrackFileRecord | null
124
+ }
125
+
126
+ export interface SlopcameraSoundtrackGridInput {
127
+ readonly sourcePath: string
128
+ readonly outputPath?: string
129
+ readonly format?: SlopcameraSoundtrackFormat
130
+ /** Where the soundtrack's first beat lands on the video timeline. */
131
+ readonly startUs?: number
132
+ }
133
+
134
+ export interface SlopcameraSoundtrackGrid {
135
+ readonly schemaVersion: 1
136
+ readonly kind: "slopcamera.soundtrack-grid"
137
+ readonly digest: string
138
+ readonly title: string
139
+ readonly music: SlopcameraSoundtrackMusicTiming
140
+ readonly beatUnit: number
141
+ /** 0–100. Swing delays off-beat sixteenths only; beats and bars stay on the grid. */
142
+ readonly swing: number
143
+ readonly bars: number
144
+ readonly beats: number
145
+ readonly startUs: number
146
+ readonly durationUs: number
147
+ readonly endUs: number
148
+ readonly sections: readonly SlopcameraSoundtrackCue[]
149
+ }
150
+
151
+ export interface SlopcameraSoundtrackGridReceipt extends Omit<SlopcameraSoundtrackGrid, "kind" | "schemaVersion"> {
152
+ readonly receiptVersion: 1
153
+ readonly operation: "slopcamera.soundtrack.grid"
154
+ readonly engine: typeof slopcameraSoundtrackEngine
155
+ readonly source: SlopcameraSoundtrackSourceRecord
156
+ readonly scoreKind: "loop" | "song"
157
+ readonly warnings: readonly SlopcameraSoundtrackWarning[]
158
+ /** The grid document, written only when an output path was supplied. */
159
+ readonly output: SlopcameraSoundtrackFileRecord | null
160
+ }
161
+
162
+ /** The beat-grid fields the projection reads; structurally the library's grid. */
163
+ export interface SlopcameraSoundtrackBeatGrid {
164
+ readonly title: string
165
+ readonly bpm: number
166
+ readonly beatsPerBar: number
167
+ readonly beatUnit: number
168
+ readonly swing: number
169
+ readonly secondsPerBeat: number
170
+ readonly bars: number
171
+ readonly beats: number
172
+ readonly sections: readonly {
173
+ readonly index: number
174
+ readonly label: string
175
+ readonly repeat: number
176
+ readonly startBar: number
177
+ readonly endBar: number
178
+ readonly startBeat: number
179
+ readonly endBeat: number
180
+ }[]
181
+ }
182
+
183
+ type BeatGrid = SlopcameraSoundtrackBeatGrid
184
+
185
+ interface LoadedScore {
186
+ readonly format: SlopcameraSoundtrackFormat
187
+ readonly document: Record<string, unknown>
188
+ readonly warnings: readonly SlopcameraSoundtrackWarning[]
189
+ }
190
+
191
+ type LibraryFailure = { readonly ok: false; readonly error: { readonly code: string; readonly message: string } }
192
+
193
+ function invalidSource(message: string): never {
194
+ throw new SlopcameraSoundtrackError("INVALID_SOUNDTRACK_SOURCE", message)
195
+ }
196
+
197
+ function invalidInput(message: string): never {
198
+ throw new SlopcameraSoundtrackError("INVALID_SOUNDTRACK_INPUT", message)
199
+ }
200
+
201
+ function printable(value: string, limit = 240): string {
202
+ const text = [...value]
203
+ .filter((character) => {
204
+ const code = character.codePointAt(0) ?? 0
205
+ return code >= 0x20 && code !== 0x7f
206
+ })
207
+ .join("")
208
+ return text.length > limit ? `${text.slice(0, limit - 3)}...` : text
209
+ }
210
+
211
+ function libraryFailure(result: LibraryFailure, action: string): never {
212
+ return invalidSource(`${action}: ${printable(result.error.code, 64)}: ${printable(result.error.message)}`)
213
+ }
214
+
215
+ function isRecord(value: unknown): value is Record<string, unknown> {
216
+ return typeof value === "object" && value !== null && !Array.isArray(value)
217
+ }
218
+
219
+ /** Standard MIDI files open with the `MThd` header chunk. */
220
+ function isMidi(bytes: Uint8Array): boolean {
221
+ return bytes.length >= 4 && bytes[0] === 0x4d && bytes[1] === 0x54 && bytes[2] === 0x68 && bytes[3] === 0x64
222
+ }
223
+
224
+ export function detectSlopcameraSoundtrackFormat(bytes: Uint8Array): SlopcameraSoundtrackFormat {
225
+ if (isMidi(bytes)) return "midi"
226
+ const text = decodeUtf8Source(bytes, "Soundtrack source")
227
+ if (text.trimStart().startsWith("{")) return "json"
228
+ return /^[ \t]*section[ \t]/mu.test(text) ? "song" : "compose"
229
+ }
230
+
231
+ async function guarded<T>(work: () => Promise<T>, action: string): Promise<T> {
232
+ try {
233
+ return await work()
234
+ } catch (error) {
235
+ if (error instanceof SlopcameraSoundtrackError || error instanceof BoundedFileError) throw error
236
+ return invalidSource(`${action}: ${printable(error instanceof Error ? error.message : String(error))}`)
237
+ }
238
+ }
239
+
240
+ async function loadScore(
241
+ bytes: Uint8Array,
242
+ requested: SlopcameraSoundtrackFormat | undefined,
243
+ ): Promise<LoadedScore> {
244
+ const format = requested ?? detectSlopcameraSoundtrackFormat(bytes)
245
+ if (format === "midi") {
246
+ if (!isMidi(bytes)) invalidSource("A MIDI source must be a Standard MIDI file.")
247
+ const result = await guarded(async () => await importMidi(bytes.slice()), "MIDI import failed")
248
+ if (!result.ok) libraryFailure(result, "MIDI import failed")
249
+ return {
250
+ format,
251
+ document: result.document,
252
+ warnings: result.warnings.map((warning) => ({
253
+ code: printable(warning.code, 64),
254
+ message: printable(warning.message),
255
+ })),
256
+ }
257
+ }
258
+ const text = decodeUtf8Source(bytes, "Soundtrack source")
259
+ if (format === "json") {
260
+ let value: unknown
261
+ try {
262
+ value = JSON.parse(text)
263
+ } catch {
264
+ invalidSource("Soundtrack JSON is not valid JSON.")
265
+ }
266
+ if (!isRecord(value)) invalidSource("Soundtrack JSON must be an object.")
267
+ return { format, document: value, warnings: [] }
268
+ }
269
+ if (format === "song") {
270
+ // A song source is template text supplied as the only template, so the
271
+ // bundled template set is never read.
272
+ const result = await guarded(
273
+ async () => await applyTemplate("songs/source", { sources: [{ kind: "songs", name: "source", text }] }),
274
+ "Song compose failed",
275
+ )
276
+ if (!result.ok) libraryFailure(result, "Song compose failed")
277
+ if (!("document" in result)) invalidSource("Song compose returned no document.")
278
+ return {
279
+ format,
280
+ document: result.document,
281
+ warnings: result.warnings.map((message) => ({ code: "song", message: printable(message) })),
282
+ }
283
+ }
284
+ const result = await guarded(async () => await compose(text), "Compose failed")
285
+ if (!result.ok) libraryFailure(result, "Compose failed")
286
+ return { format, document: result.document, warnings: [] }
287
+ }
288
+
289
+ async function readScore(
290
+ path: string,
291
+ format: SlopcameraSoundtrackFormat | undefined,
292
+ ): Promise<{ readonly score: LoadedScore; readonly source: SlopcameraSoundtrackSourceRecord }> {
293
+ if (format !== undefined && !slopcameraSoundtrackFormats.includes(format)) {
294
+ invalidInput(`format must be one of ${slopcameraSoundtrackFormats.join(", ")}.`)
295
+ }
296
+ const bytes = await readBoundedFile(path, slopcameraSoundtrackLimits.sourceBytes, "Soundtrack source")
297
+ const score = await loadScore(bytes, format)
298
+ return {
299
+ score,
300
+ source: { path, sha256: sha256Hex(bytes), bytes: bytes.byteLength, format: score.format },
301
+ }
302
+ }
303
+
304
+ async function gridOf(document: Record<string, unknown>): Promise<BeatGrid> {
305
+ const grid = await guarded(async () => await beatGrid(document), "Beat grid failed")
306
+ if (!grid.ok) libraryFailure(grid, "Beat grid failed")
307
+ return grid
308
+ }
309
+
310
+ function outOfRange(message: string): never {
311
+ throw new SlopcameraSoundtrackError("SOUNDTRACK_OUT_OF_RANGE", message)
312
+ }
313
+
314
+ /** Microseconds from beat zero to `beat` at a constant tempo. */
315
+ export function soundtrackBeatTimeUs(beat: number, bpm: number): number {
316
+ return Math.round((beat * 60_000_000) / bpm)
317
+ }
318
+
319
+ function checkedTiming(grid: BeatGrid): { readonly bpm: number; readonly beatsPerBar: number } {
320
+ const limits = slopcameraSoundtrackLimits
321
+ if (!Number.isFinite(grid.bpm) || grid.bpm < limits.minBpm || grid.bpm > limits.maxBpm) {
322
+ outOfRange(`Tempo ${String(grid.bpm)} BPM is outside ${String(limits.minBpm)}–${String(limits.maxBpm)} BPM.`)
323
+ }
324
+ if (
325
+ !Number.isSafeInteger(grid.beatsPerBar)
326
+ || grid.beatsPerBar < limits.minBeatsPerBar
327
+ || grid.beatsPerBar > limits.maxBeatsPerBar
328
+ ) {
329
+ outOfRange(`Meter of ${String(grid.beatsPerBar)} beats per bar is outside ${String(limits.minBeatsPerBar)}–${String(limits.maxBeatsPerBar)}.`)
330
+ }
331
+ // The library rounds seconds per beat to microseconds; a larger gap means
332
+ // its beat is not the tempo's beat and the projection would drift.
333
+ if (!Number.isFinite(grid.secondsPerBeat) || Math.abs(grid.secondsPerBeat - 60 / grid.bpm) > 1e-6) {
334
+ outOfRange("The beat grid's beat length disagrees with its tempo.")
335
+ }
336
+ return { bpm: grid.bpm, beatsPerBar: grid.beatsPerBar }
337
+ }
338
+
339
+ function durationUsOf(grid: BeatGrid, bpm: number): number {
340
+ if (!Number.isSafeInteger(grid.beats) || grid.beats < 1) outOfRange("The score has no beats.")
341
+ const durationUs = soundtrackBeatTimeUs(grid.beats, bpm)
342
+ if (durationUs > slopcameraSoundtrackLimits.maxTimeUs) {
343
+ outOfRange("The score is longer than the one-hour timeline bound.")
344
+ }
345
+ return durationUs
346
+ }
347
+
348
+ async function publishJson(path: string, value: unknown): Promise<SlopcameraSoundtrackFileRecord> {
349
+ const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8")
350
+ if (bytes.byteLength > slopcameraSoundtrackLimits.outputBytes) {
351
+ outOfRange("Soundtrack output exceeds the output byte limit.")
352
+ }
353
+ await publishReplaceableFile(path, bytes)
354
+ return { path, sha256: sha256Hex(bytes), bytes: bytes.byteLength }
355
+ }
356
+
357
+ async function checkOutputPath(sourcePath: string, outputPath: string | undefined): Promise<void> {
358
+ if (outputPath === undefined) return
359
+ if (!outputPath.toLowerCase().endsWith(".json")) invalidInput("outputPath must end in .json.")
360
+ if (await hasPathCollision([sourcePath, outputPath])) invalidInput("outputPath must differ from sourcePath.")
361
+ }
362
+
363
+ /**
364
+ * Parse and verify one loop or song from compose text, song text, Soundfish
365
+ * JSON or a Standard MIDI file. With an output path, publish the verified
366
+ * lossless score document; without one, write nothing.
367
+ */
368
+ export async function composeSlopcameraSoundtrack(
369
+ input: SlopcameraSoundtrackComposeInput,
370
+ ): Promise<SlopcameraSoundtrackComposeReceipt> {
371
+ await checkOutputPath(input.sourcePath, input.outputPath)
372
+ const { score, source } = await readScore(input.sourcePath, input.format)
373
+ const verified = await guarded(async () => await verify(score.document), "Verification failed")
374
+ if (!verified.ok) libraryFailure(verified, "Verification failed")
375
+ const grid = await gridOf(score.document)
376
+ const { bpm, beatsPerBar } = checkedTiming(grid)
377
+ const durationUs = durationUsOf(grid, bpm)
378
+ const output = input.outputPath === undefined ? null : await publishJson(input.outputPath, score.document)
379
+ return {
380
+ receiptVersion: 1,
381
+ operation: "slopcamera.soundtrack.compose",
382
+ engine: slopcameraSoundtrackEngine,
383
+ source,
384
+ kind: verified.kind,
385
+ title: printable(grid.title, 256),
386
+ digest: verified.digest,
387
+ contentId: verified.kind === "loop" ? verified.musicCid : verified.arrangementCid,
388
+ bpm,
389
+ beatsPerBar,
390
+ bars: grid.bars,
391
+ durationUs,
392
+ tracks: verified.kind === "loop" ? verified.tracks : null,
393
+ sections: verified.kind === "song" ? verified.sections : null,
394
+ loops: verified.kind === "song" ? verified.loops : null,
395
+ warnings: score.warnings.slice(0, slopcameraSoundtrackLimits.warnings),
396
+ output,
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Project a score's tempo, meter and sections onto the video timeline. Beat
402
+ * zero is the soundtrack's first beat at `startUs`, so `music` drops straight
403
+ * into an HTML scene request and every cue lands on a beat of that clock.
404
+ */
405
+ export function projectSlopcameraSoundtrackGrid(
406
+ grid: BeatGrid,
407
+ digest: string,
408
+ startUs = 0,
409
+ ): SlopcameraSoundtrackGrid {
410
+ if (!Number.isSafeInteger(startUs) || startUs < 0 || startUs > slopcameraSoundtrackLimits.maxTimeUs) {
411
+ invalidInput(`startUs must be an integer from 0 through ${String(slopcameraSoundtrackLimits.maxTimeUs)}.`)
412
+ }
413
+ const { bpm, beatsPerBar } = checkedTiming(grid)
414
+ const durationUs = durationUsOf(grid, bpm)
415
+ if (startUs + durationUs > slopcameraSoundtrackLimits.maxTimeUs) {
416
+ outOfRange("startUs plus the score duration exceeds the one-hour timeline bound.")
417
+ }
418
+ if (grid.sections.length === 0 || grid.sections.length > slopcameraSoundtrackLimits.sections) {
419
+ outOfRange(`A score must have 1–${String(slopcameraSoundtrackLimits.sections)} sections in play order.`)
420
+ }
421
+ let previousEndBeat = 0
422
+ const sections = grid.sections.map((section) => {
423
+ if (
424
+ !Number.isSafeInteger(section.startBeat)
425
+ || !Number.isSafeInteger(section.endBeat)
426
+ || section.startBeat !== previousEndBeat
427
+ || section.endBeat <= section.startBeat
428
+ || section.endBeat > grid.beats
429
+ ) {
430
+ outOfRange("Beat grid sections must tile the score in play order.")
431
+ }
432
+ previousEndBeat = section.endBeat
433
+ return {
434
+ index: section.index,
435
+ label: printable(section.label, 128),
436
+ repeat: section.repeat,
437
+ startBar: section.startBar,
438
+ endBar: section.endBar,
439
+ startBeat: section.startBeat,
440
+ endBeat: section.endBeat,
441
+ startUs: startUs + soundtrackBeatTimeUs(section.startBeat, bpm),
442
+ endUs: startUs + soundtrackBeatTimeUs(section.endBeat, bpm),
443
+ }
444
+ })
445
+ if (previousEndBeat !== grid.beats) outOfRange("Beat grid sections must cover the whole score.")
446
+ return {
447
+ schemaVersion: 1,
448
+ kind: "slopcamera.soundtrack-grid",
449
+ digest,
450
+ title: printable(grid.title, 256),
451
+ music: { bpm, beatOffsetUs: startUs, beatsPerBar },
452
+ beatUnit: grid.beatUnit,
453
+ swing: grid.swing,
454
+ bars: grid.bars,
455
+ beats: grid.beats,
456
+ startUs,
457
+ durationUs,
458
+ endUs: startUs + durationUs,
459
+ sections,
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Derive music timing and section cues from a score. With an output path,
465
+ * publish the grid document; without one, write nothing.
466
+ */
467
+ export async function deriveSlopcameraSoundtrackGrid(
468
+ input: SlopcameraSoundtrackGridInput,
469
+ ): Promise<SlopcameraSoundtrackGridReceipt> {
470
+ await checkOutputPath(input.sourcePath, input.outputPath)
471
+ const startUs = input.startUs ?? 0
472
+ const { score, source } = await readScore(input.sourcePath, input.format)
473
+ const verified = await guarded(async () => await verify(score.document), "Verification failed")
474
+ if (!verified.ok) libraryFailure(verified, "Verification failed")
475
+ const grid = await gridOf(score.document)
476
+ const projected = projectSlopcameraSoundtrackGrid(grid, verified.digest, startUs)
477
+ const output = input.outputPath === undefined ? null : await publishJson(input.outputPath, projected)
478
+ const { kind: _kind, schemaVersion: _schemaVersion, ...fields } = projected
479
+ return {
480
+ receiptVersion: 1,
481
+ operation: "slopcamera.soundtrack.grid",
482
+ engine: slopcameraSoundtrackEngine,
483
+ source,
484
+ scoreKind: verified.kind,
485
+ ...fields,
486
+ warnings: score.warnings.slice(0, slopcameraSoundtrackLimits.warnings),
487
+ output,
488
+ }
489
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SLOPCAMERA_VERSION = "3.8.1" as const
1
+ export const SLOPCAMERA_VERSION = "3.9.1" as const