@audiofool/moondog 0.3.0-beta.1 → 0.3.0-beta.11

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.
Files changed (57) hide show
  1. package/README.md +30 -4
  2. package/package.json +1 -1
  3. package/scripts/moondog.mjs +148 -14
  4. package/src/core/apple-projection-domain-services.mjs +4 -0
  5. package/src/core/capability-catalog.mjs +49 -0
  6. package/src/core/library-store.mjs +500 -0
  7. package/src/core/listening-now.mjs +80 -6
  8. package/src/core/listening-profile-domain-services.mjs +4 -0
  9. package/src/core/moondog-application.mjs +578 -32
  10. package/src/i18n/index.mjs +6 -3
  11. package/src/i18n/messages/de.mjs +1 -0
  12. package/src/i18n/messages/en.mjs +2 -0
  13. package/src/i18n/messages/es.mjs +1 -0
  14. package/src/i18n/messages/fr.mjs +1 -0
  15. package/src/i18n/messages/it.mjs +1 -0
  16. package/src/i18n/messages/ja.mjs +1 -0
  17. package/src/i18n/messages/ko.mjs +1 -0
  18. package/src/i18n/messages/pt.mjs +1 -0
  19. package/src/i18n/messages/zh-Hant.mjs +1 -0
  20. package/src/i18n/messages/zh.mjs +1 -0
  21. package/src/i18n/preferences.mjs +24 -0
  22. package/src/i18n/screen/es.mjs +94 -0
  23. package/src/i18n/screen/ja.mjs +94 -0
  24. package/src/i18n/screen/pt.mjs +94 -0
  25. package/src/i18n/screen/zh.mjs +94 -0
  26. package/src/integrations/apple-music/music-app.mjs +9 -3
  27. package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
  28. package/src/integrations/netease/ncm-cli-player.mjs +12 -6
  29. package/src/integrations/playlist-links.mjs +102 -0
  30. package/src/integrations/public-playlists.mjs +39 -8
  31. package/src/integrations/spotify/service.mjs +22 -1
  32. package/src/integrations/stable-audio/cover.mjs +324 -0
  33. package/src/integrations/stable-audio/local-generator.mjs +259 -87
  34. package/src/integrations/stable-audio/setup.mjs +653 -0
  35. package/src/integrations/stable-audio/sources.mjs +106 -0
  36. package/src/integrations/web/deepseek-web.mjs +1 -1
  37. package/src/integrations/web/web-research.mjs +84 -17
  38. package/src/integrations/web/web-result.mjs +60 -12
  39. package/src/profile/listening-history-store.mjs +55 -1
  40. package/src/profile/listening-profile-projection.mjs +1 -1
  41. package/src/profile/music-import-bundle.mjs +3 -1
  42. package/src/profile/music-providers.mjs +3 -0
  43. package/src/profile/taste-compiler.mjs +14 -5
  44. package/src/runtime/pi/agent-runtime.mjs +359 -37
  45. package/src/runtime/pi/configured-runtime.mjs +3 -0
  46. package/src/runtime/pi/image-input.mjs +148 -0
  47. package/src/runtime/pi/model-roles.mjs +121 -0
  48. package/src/runtime/pi/model-settings.mjs +63 -2
  49. package/src/runtime/pi/model-switch.mjs +26 -0
  50. package/src/runtime/pi/model-transport.mjs +34 -4
  51. package/src/runtime/pi/record-name.mjs +80 -0
  52. package/src/runtime/pi/reply-language.mjs +45 -0
  53. package/src/runtime/pi/runtime-settings.mjs +4 -2
  54. package/src/runtime/pi/tool-arguments.mjs +89 -0
  55. package/src/surfaces/cli/command-completions.mjs +15 -1
  56. package/src/surfaces/cli/model-roles-command.mjs +60 -0
  57. package/src/surfaces/cli/tui.mjs +419 -19
@@ -0,0 +1,324 @@
1
+ // The sleeve of a track Moondog made: a grid of characters, drawn as an SVG, never an image model.
2
+ // It reads only the track's receipt: the seed, what shaped the sound, and how loud the track ran from
3
+ // start to end. So one track always has one cover, and a stream that keeps only its newest audio
4
+ // still draws every cover. The loud band across the middle is the track itself; the field behind it
5
+ // takes its characters and colour from the track's textures, genres and moods.
6
+ import { open, readFile } from "node:fs/promises";
7
+ import path from "node:path";
8
+
9
+ // How many loudness readings a receipt keeps, from the start of the track to its end.
10
+ export const LOUDNESS_POINTS = 64;
11
+ // The grid: 40 columns by 24 rows on a 600-point square, so a cell is about as tall as a monospace glyph.
12
+ const COLUMNS = 40;
13
+ const ROWS = 24;
14
+ const SIZE = 600;
15
+ const MARGIN = 30;
16
+ const CELL_W = (SIZE - 2 * MARGIN) / COLUMNS;
17
+ const CELL_H = (SIZE - 2 * MARGIN) / ROWS;
18
+ // The rows the art fills; the rows above and below carry the readout.
19
+ const ART_TOP = 2;
20
+ const ART_ROWS = 19;
21
+ export const COVER_VERSION = 1;
22
+
23
+ // ── Loudness ──────────────────────────────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * How loud a WAV runs from start to end: `points` readings from 0 (30 dB under its loudest stretch,
27
+ * or quieter) to 1 (its loudest). Reads 16-, 24- and 32-bit PCM and 32-bit float. Null when the file
28
+ * is not a WAV it can read.
29
+ */
30
+ export async function readLoudness(file, points = LOUDNESS_POINTS) {
31
+ const handle = await open(file, "r");
32
+ try {
33
+ const size = (await handle.stat()).size;
34
+ const head = Buffer.alloc(12);
35
+ await handle.read(head, 0, 12, 0);
36
+ if (head.toString("ascii", 0, 4) !== "RIFF" || head.toString("ascii", 8, 12) !== "WAVE") return null;
37
+ let format = null;
38
+ let data = null;
39
+ for (let offset = 12; offset + 8 <= size && !data;) {
40
+ const chunk = Buffer.alloc(8);
41
+ await handle.read(chunk, 0, 8, offset);
42
+ const id = chunk.toString("ascii", 0, 4);
43
+ const length = chunk.readUInt32LE(4);
44
+ if (id === "fmt ") {
45
+ const body = Buffer.alloc(Math.min(length, 40));
46
+ await handle.read(body, 0, body.length, offset + 8);
47
+ let code = body.readUInt16LE(0);
48
+ // WAVE_FORMAT_EXTENSIBLE names the real format in its subformat.
49
+ if (code === 0xfffe && body.length >= 26) code = body.readUInt16LE(24);
50
+ format = { code, channels: body.readUInt16LE(2), bits: body.readUInt16LE(14) };
51
+ } else if (id === "data") {
52
+ data = { start: offset + 8, length: Math.min(length, size - offset - 8) };
53
+ }
54
+ offset += 8 + length + (length % 2);
55
+ }
56
+ if (!format || !data || !format.channels) return null;
57
+ const bytes = format.bits / 8;
58
+ const sample = format.code === 3 && format.bits === 32 ? (buffer, at) => buffer.readFloatLE(at)
59
+ : format.code === 1 && format.bits === 16 ? (buffer, at) => buffer.readInt16LE(at) / 32768
60
+ : format.code === 1 && format.bits === 24 ? (buffer, at) => buffer.readIntLE(at, 3) / 8388608
61
+ : format.code === 1 && format.bits === 32 ? (buffer, at) => buffer.readInt32LE(at) / 2147483648 : null;
62
+ if (!sample) return null;
63
+ const frameBytes = bytes * format.channels;
64
+ const frames = Math.floor(data.length / frameBytes);
65
+ if (frames < points) return null;
66
+ const sums = new Float64Array(points);
67
+ const counts = new Float64Array(points);
68
+ const chunkFrames = 65_536;
69
+ const buffer = Buffer.alloc(chunkFrames * frameBytes);
70
+ for (let frame = 0; frame < frames; frame += chunkFrames) {
71
+ const count = Math.min(chunkFrames, frames - frame);
72
+ const { bytesRead } = await handle.read(buffer, 0, count * frameBytes, data.start + frame * frameBytes);
73
+ const read = Math.floor(bytesRead / frameBytes);
74
+ for (let index = 0; index < read; index += 1) {
75
+ const point = Math.min(points - 1, Math.floor(((frame + index) * points) / frames));
76
+ let energy = 0;
77
+ for (let channel = 0; channel < format.channels; channel += 1) {
78
+ const value = sample(buffer, index * frameBytes + channel * bytes);
79
+ energy += value * value;
80
+ }
81
+ sums[point] += energy / format.channels;
82
+ counts[point] += 1;
83
+ }
84
+ if (read < count) break;
85
+ }
86
+ const decibels = [...sums].map((sum, point) => 10 * Math.log10(Math.max(1e-10, sum / Math.max(1, counts[point]))));
87
+ const loudest = Math.max(...decibels);
88
+ return decibels.map((db) => Math.round(Math.max(0, Math.min(1, (db - (loudest - 30)) / 30)) * 1000) / 1000);
89
+ } finally {
90
+ await handle.close();
91
+ }
92
+ }
93
+
94
+ // ── The look, from what shaped the sound ─────────────────────────────────────────────────────
95
+
96
+ // Each glyph ramp runs from empty to dense. The track's words pick one.
97
+ const RAMPS = [
98
+ { name: "grain", ramp: " .,:;irsXA253hMHGS#9B&@", words: ["tape", "crackle", "vinyl", "lofi", "lo-fi", "dust", "noise", "grit", "distort", "saturat", "fuzz", "analog", "cassette", "hiss", "warble", "boom bap"] },
99
+ { name: "soft", ramp: " .'`-~:=+*", words: ["ambient", "reverb", "wash", "air", "dream", "ether", "drone", "shimmer", "pad", "haze", "mist", "calm", "gentle", "soft", "float", "shoegaze", "new age"] },
100
+ { name: "digital", ramp: " .-:=+01x#", words: ["electronic", "techno", "house", "synth", "glitch", "digital", "idm", "trance", "edm", "chip", "bit", "modular", "acid", "breakbeat", "drum and bass", "dubstep", "electro"] },
101
+ { name: "strings", ramp: " .,-~:;!|/", words: ["piano", "guitar", "string", "folk", "jazz", "acoustic", "orchestra", "wood", "cello", "violin", "harp", "classical", "choir", "brass", "bass clarinet", "upright"] },
102
+ ];
103
+ const DEFAULT_RAMP = " .:-=+*#%@";
104
+
105
+ // Hues for the words a track carries; light paper for the bright ones.
106
+ const HUES = [
107
+ { hue: 32, words: ["warm", "sun", "amber", "golden", "tape", "vinyl", "lofi", "lo-fi", "soul", "funk", "dust", "cassette", "boom bap"] },
108
+ { hue: 232, words: ["night", "dark", "noir", "midnight", "melanchol", "sad", "moody", "deep", "nocturn", "cold", "brooding"] },
109
+ { hue: 182, words: ["ambient", "ocean", "water", "aquatic", "chill", "calm", "dream", "float", "reverb", "shoegaze", "wash", "rain"] },
110
+ { hue: 350, words: ["jazz", "blues", "romantic", "lounge", "love", "sensual", "r&b", "neo soul"] },
111
+ { hue: 286, words: ["electronic", "techno", "synth", "neon", "glitch", "acid", "cyber", "trance", "house", "electro"] },
112
+ { hue: 96, words: ["folk", "acoustic", "pastoral", "forest", "organic", "nature", "country", "earthy"] },
113
+ { hue: 46, light: true, words: ["bright", "happy", "pop", "upbeat", "euphoric", "joy", "sunny", "playful", "uplifting"] },
114
+ ];
115
+
116
+ // A small seeded generator, so a seed always draws the same cover.
117
+ function random(seed) {
118
+ let state = seed >>> 0;
119
+ return () => {
120
+ state = (state + 0x6d2b79f5) >>> 0;
121
+ let value = state;
122
+ value = Math.imul(value ^ (value >>> 15), value | 1);
123
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
124
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
125
+ };
126
+ }
127
+ const hash = (text) => [...String(text)].reduce((sum, character) => (Math.imul(sum, 31) + character.codePointAt(0)) >>> 0, 2166136261);
128
+
129
+ // The words that shaped the track: texture counts most, then genre, mood and the rest.
130
+ function wordsOf(receipt) {
131
+ const weights = { texture: 3, genre: 2, mood: 2, instrument: 1, rhythm: 1 };
132
+ return (Array.isArray(receipt?.shaped_by) ? receipt.shaped_by : [])
133
+ .filter((entry) => typeof entry?.term === "string")
134
+ .map((entry) => ({ term: entry.term.toLowerCase(), weight: weights[entry.facet] ?? 1 }));
135
+ }
136
+ function bestMatch(options, words) {
137
+ let best = null;
138
+ let bestScore = 0;
139
+ for (const option of options) {
140
+ const score = words.reduce((sum, { term, weight }) => sum + (option.words.some((word) => term.includes(word)) ? weight : 0), 0);
141
+ if (score > bestScore) { best = option; bestScore = score; }
142
+ }
143
+ return best;
144
+ }
145
+
146
+ const hsl = (h, s, l) => `hsl(${Math.round(((h % 360) + 360) % 360)} ${Math.round(s)}% ${Math.round(l)}%)`;
147
+
148
+ // ── The field ────────────────────────────────────────────────────────────────────────────────
149
+
150
+ // A field behind the band: a few seeded waves, or rings around a point, as the seed chooses. Values 0 to 1.
151
+ function backgroundField(next) {
152
+ const kind = next() < 0.5 ? "waves" : "rings";
153
+ const waves = Array.from({ length: 3 }, () => ({
154
+ angle: next() * Math.PI, frequency: 2 + next() * 7, phase: next() * Math.PI * 2, weight: 0.5 + next(),
155
+ }));
156
+ const center = { x: 0.15 + next() * 0.7, y: 0.15 + next() * 0.7 };
157
+ const ringFrequency = 9 + next() * 10;
158
+ const total = waves.reduce((sum, wave) => sum + wave.weight, 0);
159
+ return (x, y) => {
160
+ let value = 0;
161
+ for (const wave of waves) {
162
+ const along = x * Math.cos(wave.angle) + y * Math.sin(wave.angle);
163
+ value += wave.weight * Math.sin(along * wave.frequency * Math.PI + wave.phase);
164
+ }
165
+ value /= total;
166
+ if (kind === "rings") {
167
+ const distance = Math.hypot((x - center.x) * 1.0, y - center.y);
168
+ value = 0.6 * Math.sin(distance * ringFrequency * Math.PI) + 0.4 * value;
169
+ }
170
+ return (value + 1) / 2;
171
+ };
172
+ }
173
+
174
+ // Readings spread over `columns`, so 64 kept readings fit the grid's 40 columns.
175
+ function resample(values, columns) {
176
+ if (!Array.isArray(values) || values.length < 2) return null;
177
+ return Array.from({ length: columns }, (_, column) => {
178
+ const at = (column / (columns - 1)) * (values.length - 1);
179
+ const low = Math.floor(at);
180
+ const high = Math.min(values.length - 1, low + 1);
181
+ const value = values[low] + (values[high] - values[low]) * (at - low);
182
+ return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
183
+ });
184
+ }
185
+
186
+ // Made tracks are loud almost all the way through, so the band shows each track against its own
187
+ // quietest and loudest stretch; the quietest still keeps a quarter of the height.
188
+ function stretch(values) {
189
+ if (!values) return null;
190
+ const low = Math.min(...values);
191
+ const high = Math.max(...values);
192
+ const range = high - low;
193
+ return values.map((value) => 0.25 + 0.75 * (range >= 0.05 ? (value - low) / range : value));
194
+ }
195
+
196
+ // ── The readout ──────────────────────────────────────────────────────────────────────────────
197
+
198
+ // A character takes two cells when it is wide, as CJK is.
199
+ const wide = (character) => /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|[\u{20000}-\u{3fffd}]/u.test(character);
200
+ const cellsOf = (text) => [...text].reduce((sum, character) => sum + (wide(character) ? 2 : 1), 0);
201
+ function fit(text, cells) {
202
+ if (cellsOf(text) <= cells) return text;
203
+ let out = "";
204
+ for (const character of text) {
205
+ if (cellsOf(`${out}${character}`) > cells - 1) break;
206
+ out += character;
207
+ }
208
+ return `${out}…`;
209
+ }
210
+ const clock = (seconds) => `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
211
+
212
+ function readout(receipt) {
213
+ const position = Number.isInteger(receipt?.stream?.position) ? receipt.stream.position : null;
214
+ const date = typeof receipt?.created_at === "string" ? receipt.created_at.slice(0, 10).replaceAll("-", ".") : "";
215
+ const featured = Array.isArray(receipt?.stream?.featured) && receipt.stream.featured.length ? receipt.stream.featured
216
+ : wordsOf(receipt).slice(0, 3).map(({ term }) => term);
217
+ const seconds = receipt?.audio?.duration_seconds ?? receipt?.generator?.seconds;
218
+ const bpm = Number.isInteger(receipt?.bpm) ? receipt.bpm : null;
219
+ const seed = Number.isInteger(receipt?.generator?.seed) ? receipt.generator.seed : null;
220
+ return {
221
+ left: "moondog",
222
+ right: position ? `on air #${String(position).padStart(2, "0")}` : date,
223
+ terms: featured.length ? fit(`// ${featured.join(", ")}`, COLUMNS) : "// made on this mac",
224
+ facts: fit([Number.isFinite(seconds) ? clock(seconds) : null, bpm ? `${bpm} bpm` : null, seed !== null ? `seed ${seed}` : null]
225
+ .filter(Boolean).join(" "), COLUMNS),
226
+ };
227
+ }
228
+
229
+ // ── Drawing ──────────────────────────────────────────────────────────────────────────────────
230
+
231
+ const escape = (text) => text.replace(/[&<>"]/gu, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[character]);
232
+ const x = (column) => (MARGIN + (column + 0.5) * CELL_W).toFixed(1);
233
+ const y = (row) => (MARGIN + (row + 0.78) * CELL_H).toFixed(1);
234
+
235
+ /**
236
+ * The cover of one made track: { svg, lines, look }. `lines` is the same grid as plain text, for a
237
+ * terminal; `look` names the ramp, field and hue it chose. `loudness` overrides the receipt's own
238
+ * readings, for a track made before receipts kept them.
239
+ */
240
+ export function drawCover(receipt, { loudness } = {}) {
241
+ const seed = (Number.isInteger(receipt?.generator?.seed) ? receipt.generator.seed : 0) ^ hash(receipt?.generated_track_id ?? receipt?.title ?? "");
242
+ const next = random(seed);
243
+ const words = wordsOf(receipt);
244
+ const ramp = bestMatch(RAMPS, words);
245
+ const glyphs = ramp?.ramp ?? DEFAULT_RAMP;
246
+ const tint = bestMatch(HUES, words);
247
+ const hue = (tint?.hue ?? Math.floor(next() * 360)) + (next() - 0.5) * 24;
248
+ const light = tint?.light === true;
249
+ const colours = light
250
+ ? { paper: hsl(hue, 34, 90), dim: hsl(hue, 16, 64), ink: hsl(hue, 24, 32), accent: hsl(hue, 70, 40) }
251
+ : { paper: hsl(hue, 26, 9), dim: hsl(hue, 16, 34), ink: hsl(hue, 18, 64), accent: hsl(hue, 72, 68) };
252
+ const field = backgroundField(next);
253
+ const band = stretch(resample(loudness ?? receipt?.audio?.loudness, COLUMNS));
254
+ // A little unevenness per column, so the band reads as a waveform rather than a block.
255
+ const jitter = Array.from({ length: COLUMNS }, () => 0.86 + next() * 0.28);
256
+
257
+ // Each cell holds a glyph and its tone: 0 dim, 1 ink, 2 accent.
258
+ const grid = Array.from({ length: ROWS }, () => Array.from({ length: COLUMNS }, () => ({ glyph: " ", tone: 1 })));
259
+ for (let row = 0; row < ART_ROWS; row += 1) {
260
+ for (let column = 0; column < COLUMNS; column += 1) {
261
+ const fx = column / (COLUMNS - 1);
262
+ const fy = row / (ART_ROWS - 1);
263
+ const behind = field(fx, fy);
264
+ // With a band, the field stays low so the band reads first; without one, the field is the cover.
265
+ let value = band ? Math.max(0, behind - 0.35) * 0.75 : Math.max(0, behind - 0.2) * 1.1;
266
+ let tone = band ? 0 : behind > 0.82 ? 2 : behind > 0.55 ? 1 : 0;
267
+ if (band) {
268
+ // The band is the track: tall where it is loud, mirrored about the middle row as a waveform is.
269
+ const half = 0.06 + band[column] * 0.36 * jitter[column];
270
+ const distance = Math.abs(fy - 0.5);
271
+ if (distance <= half) {
272
+ const depth = (1 - distance / half) ** 0.8;
273
+ value = Math.max(value, Math.min(1, 0.38 + 0.62 * depth + (behind - 0.5) * 0.4));
274
+ tone = distance <= half * 0.4 ? 2 : 1;
275
+ }
276
+ }
277
+ const index = Math.min(glyphs.length - 1, Math.floor(value * glyphs.length));
278
+ grid[ART_TOP + row][column] = { glyph: glyphs[index], tone };
279
+ }
280
+ }
281
+ const write = (row, text, column, tone) => {
282
+ let at = column;
283
+ for (const character of text) {
284
+ if (at >= COLUMNS) break;
285
+ grid[row][at] = { glyph: character, tone };
286
+ at += wide(character) ? 2 : 1;
287
+ if (wide(character) && at - 1 < COLUMNS) grid[row][at - 1] = { glyph: "", tone };
288
+ }
289
+ };
290
+ const info = readout(receipt);
291
+ write(0, info.left, 0, 1);
292
+ write(0, info.right, COLUMNS - cellsOf(info.right), 2);
293
+ write(ROWS - 2, info.terms, 0, 1);
294
+ write(ROWS - 1, info.facts, 0, 0);
295
+
296
+ // One text element per row and tone, each glyph at its own cell, so any monospace font keeps the grid.
297
+ const tones = [colours.dim, colours.ink, colours.accent];
298
+ const rows = grid.map((cells, row) => tones.map((fill, tone) => {
299
+ const placed = cells.map((cell, column) => ({ ...cell, column })).filter((cell) => cell.tone === tone && cell.glyph && cell.glyph !== " ");
300
+ if (!placed.length) return "";
301
+ return `<text y="${y(row)}" x="${placed.map((cell) => x(cell.column)).join(" ")}" fill="${fill}">${escape(placed.map((cell) => cell.glyph).join(""))}</text>`;
302
+ }).join("")).join("");
303
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">`
304
+ + `<rect width="${SIZE}" height="${SIZE}" fill="${colours.paper}"/>`
305
+ + `<g font-family="ui-monospace, 'SF Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace" font-size="19" font-weight="500" text-anchor="middle">${rows}</g></svg>`;
306
+ const lines = grid.map((cells) => cells.map((cell) => cell.glyph).join("").replace(/\s+$/u, ""));
307
+ return { svg, lines, look: { ramp: ramp?.name ?? "plain", hue: Math.round(((hue % 360) + 360) % 360), light, banded: Boolean(band), version: COVER_VERSION } };
308
+ }
309
+
310
+ /**
311
+ * The cover of the made track in `folder` as SVG text, or null when the folder holds no receipt.
312
+ * A track made before receipts kept its loudness is measured from its audio while it still has some.
313
+ */
314
+ export async function trackFolderCover(folder) {
315
+ let receipt;
316
+ try {
317
+ receipt = JSON.parse(await readFile(path.join(folder, "receipt.json"), "utf8"));
318
+ } catch {
319
+ return null;
320
+ }
321
+ const loudness = Array.isArray(receipt?.audio?.loudness) ? null
322
+ : await readLoudness(path.join(folder, "track.wav")).catch(() => null);
323
+ return drawCover(receipt, loudness ? { loudness } : {}).svg;
324
+ }