@audiofool/moondog 0.2.2 → 0.3.0-beta.10

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 (52) hide show
  1. package/NOTICE +12 -0
  2. package/README.md +4 -2
  3. package/licenses/stability-ai-community-license.md +58 -0
  4. package/package.json +3 -1
  5. package/scripts/moondog.mjs +171 -7
  6. package/src/core/apple-projection-domain-services.mjs +14 -0
  7. package/src/core/capability-catalog.mjs +104 -0
  8. package/src/core/library-store.mjs +500 -0
  9. package/src/core/listening-now.mjs +80 -6
  10. package/src/core/listening-profile-domain-services.mjs +12 -0
  11. package/src/core/moondog-application.mjs +754 -23
  12. package/src/i18n/index.mjs +6 -3
  13. package/src/i18n/messages/de.mjs +3 -0
  14. package/src/i18n/messages/en.mjs +4 -0
  15. package/src/i18n/messages/es.mjs +3 -0
  16. package/src/i18n/messages/fr.mjs +3 -0
  17. package/src/i18n/messages/it.mjs +3 -0
  18. package/src/i18n/messages/ja.mjs +3 -0
  19. package/src/i18n/messages/ko.mjs +3 -0
  20. package/src/i18n/messages/pt.mjs +3 -0
  21. package/src/i18n/messages/zh-Hant.mjs +3 -0
  22. package/src/i18n/messages/zh.mjs +3 -0
  23. package/src/i18n/preferences.mjs +24 -0
  24. package/src/i18n/screen/es.mjs +118 -0
  25. package/src/i18n/screen/ja.mjs +118 -0
  26. package/src/i18n/screen/pt.mjs +118 -0
  27. package/src/i18n/screen/zh.mjs +118 -0
  28. package/src/integrations/apple-music/music-app.mjs +9 -3
  29. package/src/integrations/apple-music/terminal-preview-player.mjs +68 -11
  30. package/src/integrations/netease/ncm-cli-player.mjs +12 -6
  31. package/src/integrations/playlist-links.mjs +96 -0
  32. package/src/integrations/public-playlists.mjs +10 -4
  33. package/src/integrations/spotify/service.mjs +22 -1
  34. package/src/integrations/stable-audio/cover.mjs +324 -0
  35. package/src/integrations/stable-audio/local-generator.mjs +855 -0
  36. package/src/integrations/stable-audio/setup.mjs +653 -0
  37. package/src/integrations/stable-audio/sources.mjs +106 -0
  38. package/src/profile/listening-history-store.mjs +1 -1
  39. package/src/profile/listening-profile-projection.mjs +1 -1
  40. package/src/profile/moondog-suggestions.mjs +9 -0
  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 +206 -0
  44. package/src/runtime/pi/agent-runtime.mjs +600 -38
  45. package/src/runtime/pi/model-switch.mjs +26 -0
  46. package/src/runtime/pi/model-transport.mjs +34 -4
  47. package/src/runtime/pi/record-name.mjs +80 -0
  48. package/src/runtime/pi/reply-language.mjs +45 -0
  49. package/src/runtime/pi/tool-arguments.mjs +89 -0
  50. package/src/surfaces/cli/command-completions.mjs +15 -0
  51. package/src/surfaces/cli/format-output.mjs +1 -0
  52. package/src/surfaces/cli/tui.mjs +467 -19
@@ -54,7 +54,8 @@ async function boundedJson(response) {
54
54
  catch { throw new Error("The service changed its playlist response or requires sign-in. No music was imported."); }
55
55
  }
56
56
 
57
- export async function readPublicPlaylist({ input, subjectId, capturedAt, provider, fetchImpl = fetch }) {
57
+ /** A public QQ Music or NetEase playlist by its share link: { provider, id, url, name, total, rows, skipped }. */
58
+ export async function fetchPublicPlaylist({ input, provider, fetchImpl = fetch }) {
58
59
  let url = playlistLink(input);
59
60
  let identity = playlistIdentity(url);
60
61
  // Follow only official share redirects, never arbitrary network destinations or cookies.
@@ -93,9 +94,14 @@ export async function readPublicPlaylist({ input, subjectId, capturedAt, provide
93
94
  playlistId: identity.id, playlistName: name, position: index + 1, member: "public-playlist.json",
94
95
  };
95
96
  }).filter((row) => row.id && row.title);
96
- const bytes = Buffer.from(JSON.stringify({ provider: identity.provider, id: identity.id, name, rows }));
97
- const prepared = prepareMusicBundle({ provider: identity.provider, subjectId, capturedAt, rows,
98
- digest: musicHash(bytes), size: bytes.length, members: ["public-playlist.json"], fileName: name, skipped: songs.length - rows.length,
97
+ return { provider: identity.provider, id: identity.id, url: url.href, name, total, rows, skipped: songs.length - rows.length };
98
+ }
99
+
100
+ export async function readPublicPlaylist({ input, subjectId, capturedAt, provider, fetchImpl = fetch }) {
101
+ const { provider: source, id, name, total, rows, skipped } = await fetchPublicPlaylist({ input, provider, fetchImpl });
102
+ const bytes = Buffer.from(JSON.stringify({ provider: source, id, name, rows }));
103
+ const prepared = prepareMusicBundle({ provider: source, subjectId, capturedAt, rows,
104
+ digest: musicHash(bytes), size: bytes.length, members: ["public-playlist.json"], fileName: name, skipped,
99
105
  scopeNote: `${rows.length} of ${total ?? "an unknown number of"} songs could be read; the service hides some. Only this playlist is added. It shows what you keep, not what you played, and it may not be one you made.`,
100
106
  });
101
107
  prepared.preview.source = "public-playlist";
@@ -587,7 +587,8 @@ function refusedMove(error, target, player) {
587
587
  const refused = new SpotifyServiceError("spotify_device_refused",
588
588
  `Spotify would not move playback to ${clipText(target.name, 80)}, either as a transfer or as a song started there. ` +
589
589
  `${stayed ? `Playback stays on ${stayed}${player.is_playing === false ? ", paused" : ""}. ` : ""}` +
590
- "The Spotify app on that device usually needs to be open and played once before it accepts remote control.");
590
+ "The Spotify app on that device usually needs to be open and played once before it accepts remote control. " +
591
+ "Both ways were already tried, so do not try another way in this turn; tell the listener.");
591
592
  refused.status = error?.status;
592
593
  refused.playbackStayedOn = stayed;
593
594
  return refused;
@@ -908,6 +909,26 @@ export function createSpotifyService(options = {}) {
908
909
  tracks: page ? page.items.map((entry) => entry.item).filter(Boolean) : null };
909
910
  },
910
911
 
912
+ // A whole playlist for the listener's library: its name, whether the signed-in listener owns it, and its
913
+ // songs page by page, up to 2,000. Spotify's own editorial playlists answer most apps with not found.
914
+ async readPlaylist({ playlistId } = {}, { signal } = {}) {
915
+ const id = requiredPlaylistId(String(playlistId ?? ""));
916
+ const playlist = await client.getPlaylist({ playlistId: id }, { signal });
917
+ if (!playlist) fail("spotify_playlist_unavailable", "Spotify did not describe this playlist.");
918
+ const tracks = [];
919
+ const pageSize = SPOTIFY_SERVICE_LIMITS.playlistItemsPageMax;
920
+ for (let offset = 0; offset < 2000; offset += pageSize) {
921
+ const page = await client.getPlaylistItems({ playlistId: id, limit: pageSize, offset }, { signal });
922
+ // A local file on the owner's computer has no catalog song to play or name; it stays out.
923
+ tracks.push(...page.items.filter((entry) => entry.is_local !== true).map((entry) => entry.item)
924
+ .filter((item) => item?.type === "track" && item.name && item.artists?.length && /^spotify:track:/u.test(item.uri ?? "")));
925
+ if (!page.has_more || !page.items.length) break;
926
+ }
927
+ const account = await client.getAccount({ signal }).catch((error) => (signal?.aborted ? Promise.reject(error) : null));
928
+ return { name: playlist.name, uri: playlist.uri, tracks_total: playlist.tracks_total ?? null,
929
+ owned: account?.account_id && playlist.owner_id ? account.account_id === playlist.owner_id : null, tracks };
930
+ },
931
+
911
932
  // One device decision for a whole queue request. An idle device cannot take a
912
933
  // queue entry, so the caller starts the first song there and queues the rest.
913
934
  async queueTarget(value, { signal } = {}) {
@@ -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
+ }