@audiofool/moondog 0.1.0 → 0.2.0-beta.2

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 (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +153 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +168 -36
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +278 -55
  12. package/src/core/listening-now.mjs +108 -0
  13. package/src/core/listening-profile-domain-services.mjs +153 -41
  14. package/src/core/listening-upkeep.mjs +31 -0
  15. package/src/core/moondog-application.mjs +1908 -104
  16. package/src/core/playback-preferences.mjs +97 -0
  17. package/src/core/spotify-listening-intent.mjs +121 -0
  18. package/src/core/synthetic-domain-services.mjs +24 -3
  19. package/src/demo/moondog-demo.mjs +4 -22
  20. package/src/i18n/index.mjs +285 -0
  21. package/src/i18n/messages/de.mjs +128 -0
  22. package/src/i18n/messages/en.mjs +129 -0
  23. package/src/i18n/messages/es.mjs +128 -0
  24. package/src/i18n/messages/fr.mjs +128 -0
  25. package/src/i18n/messages/it.mjs +128 -0
  26. package/src/i18n/messages/ja.mjs +128 -0
  27. package/src/i18n/messages/ko.mjs +128 -0
  28. package/src/i18n/messages/pt.mjs +128 -0
  29. package/src/i18n/messages/zh-Hant.mjs +128 -0
  30. package/src/i18n/messages/zh.mjs +128 -0
  31. package/src/i18n/preferences.mjs +34 -0
  32. package/src/i18n/screen/es.mjs +966 -0
  33. package/src/i18n/screen/ja.mjs +966 -0
  34. package/src/i18n/screen/pt.mjs +966 -0
  35. package/src/i18n/screen/zh.mjs +966 -0
  36. package/src/importers/apple-music-library/index.mjs +0 -16
  37. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  38. package/src/integrations/apple-music/catalog.mjs +123 -14
  39. package/src/integrations/apple-music/music-app.mjs +232 -0
  40. package/src/integrations/apple-music/storefront.mjs +28 -0
  41. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  42. package/src/integrations/lastfm/history.mjs +221 -0
  43. package/src/integrations/lastfm/settings.mjs +75 -0
  44. package/src/integrations/netease/ncm-cli-player.mjs +321 -0
  45. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  46. package/src/integrations/public-playlists.mjs +2 -1
  47. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  48. package/src/integrations/spotify/authentication.mjs +149 -15
  49. package/src/integrations/spotify/catalog-resolver.mjs +8 -4
  50. package/src/integrations/spotify/connection.mjs +2 -1
  51. package/src/integrations/spotify/credential-store.mjs +4 -0
  52. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  53. package/src/integrations/spotify/history-archive.mjs +1 -1
  54. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  55. package/src/integrations/spotify/recent-activity.mjs +2 -0
  56. package/src/integrations/spotify/service.mjs +873 -69
  57. package/src/integrations/spotify/web-api-client.mjs +407 -127
  58. package/src/integrations/web/brave-web.mjs +45 -0
  59. package/src/integrations/web/codex-web.mjs +15 -82
  60. package/src/integrations/web/deepseek-web.mjs +81 -0
  61. package/src/integrations/web/web-research.mjs +119 -0
  62. package/src/integrations/web/web-result.mjs +215 -0
  63. package/src/integrations/youtube-music/takeout.mjs +3 -1
  64. package/src/memory/local-memory-store.mjs +469 -41
  65. package/src/profile/history-import.mjs +42 -6
  66. package/src/profile/listener-profile-build.mjs +378 -0
  67. package/src/profile/listener-profile-store.mjs +130 -0
  68. package/src/profile/listener-profile-verification.mjs +68 -0
  69. package/src/profile/listener-profile-worker.mjs +172 -0
  70. package/src/profile/listening-history-store.mjs +424 -8
  71. package/src/profile/listening-profile-projection.mjs +242 -76
  72. package/src/profile/local-music-data.mjs +1 -1
  73. package/src/profile/music-providers.mjs +11 -7
  74. package/src/profile/profile-exploration.mjs +84 -0
  75. package/src/profile/recorded-measures.mjs +24 -0
  76. package/src/profile/spotify-archive-taste.mjs +0 -4
  77. package/src/runtime/pi/agent-runtime.mjs +2204 -605
  78. package/src/runtime/pi/authentication.mjs +34 -1
  79. package/src/runtime/pi/configured-runtime.mjs +22 -6
  80. package/src/runtime/pi/conversation-name.mjs +53 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  82. package/src/runtime/pi/model-catalog.mjs +28 -5
  83. package/src/runtime/pi/model-settings.mjs +202 -0
  84. package/src/runtime/pi/model-transport.mjs +10 -0
  85. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  86. package/src/runtime/pi/profile-build-runtime.mjs +160 -0
  87. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  88. package/src/runtime/pi/runtime-settings.mjs +10 -2
  89. package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
  90. package/src/runtime/pi/tool-trace.mjs +37 -0
  91. package/src/surfaces/cli/auth-command.mjs +12 -4
  92. package/src/surfaces/cli/brand-components.mjs +310 -132
  93. package/src/surfaces/cli/brand-theme.mjs +51 -28
  94. package/src/surfaces/cli/command-completions.mjs +58 -42
  95. package/src/surfaces/cli/format-output.mjs +199 -388
  96. package/src/surfaces/cli/history-import-view.mjs +292 -139
  97. package/src/surfaces/cli/import-guides.mjs +2 -0
  98. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  99. package/src/surfaces/cli/listening-menu.mjs +7 -4
  100. package/src/surfaces/cli/profile-command.mjs +30 -21
  101. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  102. package/src/surfaces/cli/spotify-command.mjs +54 -6
  103. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  104. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  105. package/src/surfaces/cli/terminal-art.mjs +140 -35
  106. package/src/surfaces/cli/tui.mjs +1066 -357
  107. package/src/surfaces/cli/update-command.mjs +32 -0
  108. package/src/surfaces/cli/web-command.mjs +53 -14
  109. package/src/surfaces/html/tasteprint.mjs +43 -70
  110. package/contracts/v1/capability-policy.schema.json +0 -572
  111. package/contracts/v1/profile-evidence.schema.json +0 -237
  112. package/contracts/v1/tool-invocation.schema.json +0 -318
  113. package/contracts/v1/tool-receipt.schema.json +0 -1066
  114. package/scripts/demo-studio.mjs +0 -58
  115. package/src/core/apple-data-migration.mjs +0 -50
  116. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  117. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  118. package/src/integrations/spotify/index.mjs +0 -80
  119. package/src/surfaces/web/showcase.mjs +0 -272
  120. package/src/surfaces/web/studio-page.mjs +0 -4148
  121. package/src/surfaces/web/studio.mjs +0 -2288
@@ -3,7 +3,15 @@ export const LOGO_MOTION_INTERVAL_MS = 125;
3
3
 
4
4
  const turn = Math.PI * 2;
5
5
 
6
- const dog = [
6
+ /** Record layers from faintest to strongest, matching the logo's fine grooves and solid dog. */
7
+ export const RECORD_LAYERS = Object.freeze({ groove: 1, surface: 2, figure: 3 });
8
+
9
+ // The dog's eye, in the record's unit coordinates; layouts line the title up with it.
10
+ const eye = [0.22, -0.13];
11
+
12
+ // The dog's outline runs through these points as one smooth curve (smoothOutline, below), so it
13
+ // keeps its shape without the facets of straight segments between them.
14
+ const dogPoints = [
7
15
  [-0.62, 0.48], [-0.55, 0.29], [-0.47, 0.08], [-0.42, -0.16],
8
16
  [-0.4, -0.34], [-0.32, -0.47], [-0.18, -0.61], [-0.17, -0.44],
9
17
  [-0.12, -0.43], [-0.1, -0.32], [0.02, -0.27], [0.16, -0.24],
@@ -13,6 +21,26 @@ const dog = [
13
21
  [0.07, 0.77], [-0.13, 0.75], [-0.3, 0.7], [-0.48, 0.62],
14
22
  ];
15
23
 
24
+ // A closed centripetal Catmull-Rom curve through `points`: it passes through every point, and unlike
25
+ // the uniform kind it never loops or overshoots at the sharp turns of the ear and the nose.
26
+ function smoothOutline(points, steps = 10) {
27
+ const outline = [];
28
+ const count = points.length;
29
+ for (let index = 0; index < count; index += 1) {
30
+ const p = [-1, 0, 1, 2].map((offset) => points[(index + offset + count) % count]);
31
+ const t = [0];
32
+ for (let at = 1; at < 4; at += 1) t.push(t[at - 1] + Math.hypot(p[at][0] - p[at - 1][0], p[at][1] - p[at - 1][1]) ** 0.5);
33
+ for (let step = 0; step < steps; step += 1) {
34
+ const u = t[1] + (t[2] - t[1]) * step / steps;
35
+ const mix = (a, b, from, to) => a.map((value, axis) => ((to - u) * value + (u - from) * b[axis]) / (to - from));
36
+ const [a1, a2, a3] = [mix(p[0], p[1], t[0], t[1]), mix(p[1], p[2], t[1], t[2]), mix(p[2], p[3], t[2], t[3])];
37
+ outline.push(mix(mix(a1, a2, t[0], t[2]), mix(a2, a3, t[1], t[3]), t[1], t[2]));
38
+ }
39
+ }
40
+ return outline;
41
+ }
42
+ const dog = smoothOutline(dogPoints);
43
+
16
44
  const smallAscii = [
17
45
  " /",
18
46
  " .-======-. /",
@@ -94,21 +122,25 @@ function bezier(start, first, second, end) {
94
122
  });
95
123
  }
96
124
 
97
- function drawing(columns, rows) {
125
+ function drawing(columns, rows, cellAspect) {
98
126
  const width = columns * 2;
99
127
  const height = rows * 4;
128
+ // Each dot remembers which layer inked it, so callers can tone the record like the logo.
100
129
  const dots = new Uint8Array(width * height);
101
- const radius = Math.min((width - 4) / 2.35, (height - 4) / 2.24);
130
+ let layer = RECORD_LAYERS.figure;
131
+ // A Braille dot is as tall as a quarter cell and as wide as half of one.
132
+ const stretch = cellAspect / 2;
133
+ const radius = Math.min((width - 4) / 2.35, (height - 4) * stretch / 2.24);
102
134
  const centerX = (width - 1) / 2 - radius * 0.09;
103
- const centerY = (height - 1) / 2 + radius * 0.045;
135
+ const centerY = (height - 1) / 2 + radius * 0.045 / stretch;
104
136
  const pixel = 1 / radius;
105
137
 
106
138
  function scan(left, top, right, bottom, predicate, ink) {
107
- for (let y = Math.max(0, Math.floor(centerY + top * radius)); y <= Math.min(height - 1, Math.ceil(centerY + bottom * radius)); y += 1) {
139
+ for (let y = Math.max(0, Math.floor(centerY + top * radius / stretch)); y <= Math.min(height - 1, Math.ceil(centerY + bottom * radius / stretch)); y += 1) {
108
140
  for (let x = Math.max(0, Math.floor(centerX + left * radius)); x <= Math.min(width - 1, Math.ceil(centerX + right * radius)); x += 1) {
109
141
  const nx = (x - centerX) / radius;
110
- const ny = (y - centerY) / radius;
111
- if (predicate(nx, ny, x, y)) dots[y * width + x] = ink ? 1 : 0;
142
+ const ny = (y - centerY) * stretch / radius;
143
+ if (predicate(nx, ny, x, y)) dots[y * width + x] = ink ? layer : 0;
112
144
  }
113
145
  }
114
146
  }
@@ -142,21 +174,43 @@ function drawing(columns, rows) {
142
174
  const ys = points.map(([, y]) => y);
143
175
  scan(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys),
144
176
  (x, y, dx, dy) => insidePolygon(x, y, points)
145
- && (!hatch || (dy % 4 < 2 && dx % 9 !== 0)), ink);
177
+ // The hatch runs diagonally, two dots on and two off, like an engraving's.
178
+ && (!hatch || (dx + dy) % 4 < 2), ink);
146
179
  }
147
180
 
148
- return { dots, width, height, radius, pixel, circle, disc, line, polygon };
181
+ const paintLayer = (value) => { layer = value; };
182
+ return { dots, width, height, radius, centerX, centerY, pixel, circle, disc, line, polygon, paintLayer };
149
183
  }
150
184
 
151
- function braille(columns, rows, phase) {
152
- const art = drawing(columns, rows);
153
- const { radius, pixel, circle, disc, line, polygon } = art;
185
+ // The portrait and the tonearm in the record's unit coordinates: the disc has radius 1 around 0,0.
186
+ const shapes = {
187
+ headband: bezier([-0.42, -0.03], [-0.47, -0.31], [-0.27, -0.58], [-0.18, -0.59]),
188
+ ear: [-0.365, -0.005],
189
+ nose: [0.53, 0.045],
190
+ arm: [[1.17, -1.095], [0.91, -0.8]],
191
+ cartridge: [[0.92, -0.845], [0.998, -0.777], [0.808, -0.567], [0.73, -0.635]],
192
+ // Two short highlights ride fixed grooves as the record turns: the groove's place from the rim, its angle, its length.
193
+ highlights: [[1, -0.91, 0.12], [3, 2.44, 0.09]],
194
+ };
195
+
196
+ /** The shapes the record is drawn from, for surfaces that draw it as vectors, such as the window's small mark. */
197
+ export function lunarRecordShapes() {
198
+ return structuredClone({ dog, eye, ...shapes, earRadius: 0.17, grooves: [1, 0.91, 0.8, 0.66] });
199
+ }
200
+
201
+ // The record as a field of dots, two across and four down per terminal cell, each marked with its layer.
202
+ function paint(columns, rows, phase, cellAspect) {
203
+ const art = drawing(columns, rows, cellAspect);
204
+ const { radius, pixel, circle, disc, line, polygon, paintLayer } = art;
154
205
  const detail = radius >= 39 ? 2 : radius >= 27 ? 1 : 0;
155
206
  const grooves = detail === 2
156
207
  ? [1, 0.975, 0.935, 0.895, 0.85, 0.8, 0.74, 0.67, 0.59, 0.51]
157
208
  : detail === 1 ? [1, 0.955, 0.89, 0.82, 0.74, 0.64, 0.53]
158
209
  : [1, 0.91, 0.8, 0.66];
159
- for (const groove of grooves) circle(0, 0, groove, pixel * 0.95);
210
+ paintLayer(RECORD_LAYERS.groove);
211
+ // A full dot keeps each ring continuous through its diagonals without merging neighbours.
212
+ for (const groove of grooves) circle(0, 0, groove, pixel);
213
+ paintLayer(RECORD_LAYERS.surface);
160
214
 
161
215
  const rotation = phase / LOGO_MOTION_FRAMES * turn;
162
216
  const cosine = Math.cos(rotation);
@@ -176,7 +230,8 @@ function braille(columns, rows, phase) {
176
230
 
177
231
  // These short highlights follow fixed grooves; the record geometry never deforms.
178
232
  // Painting the portrait afterward keeps the moving surface behind the dog and headphones.
179
- for (const [groove, offset, length] of [[grooves[1], -0.91, 0.12], [grooves[3], 2.44, 0.09]]) {
233
+ for (const [index, offset, length] of shapes.highlights) {
234
+ const groove = grooves[index];
180
235
  const angle = rotation + offset;
181
236
  const points = Array.from({ length: 13 }, (_, index) => {
182
237
  const position = angle - length + index / 12 * length;
@@ -186,15 +241,18 @@ function braille(columns, rows, phase) {
186
241
  disc(Math.cos(angle) * groove, Math.sin(angle) * groove, pixel * 1.1);
187
242
  }
188
243
 
244
+ paintLayer(RECORD_LAYERS.figure);
245
+ // Like the logo, a clear halo separates the dog from the grooves around it.
246
+ const { headband, ear, nose, arm, cartridge } = shapes;
247
+ line([...dog, dog[0]], pixel * 4.5, false);
248
+ line(headband, Math.max(pixel * 7, 0.11), false);
189
249
  polygon(dog, false);
190
250
  polygon(dog, true, true);
191
251
  line([...dog, dog[0]], pixel * 0.8);
192
252
 
193
- const headband = bezier([-0.42, -0.03], [-0.47, -0.31], [-0.27, -0.58], [-0.18, -0.59]);
194
253
  line(headband, Math.max(pixel * 3.5, 0.065));
195
254
  line(headband, Math.max(pixel * 1.2, 0.025), false);
196
- const earX = -0.365;
197
- const earY = -0.005;
255
+ const [earX, earY] = ear;
198
256
  const earRadius = detail > 0 ? 0.157 : 0.17;
199
257
  disc(earX, earY, earRadius + pixel * 1.6, false);
200
258
  circle(earX, earY, earRadius, pixel * 1.1);
@@ -202,11 +260,10 @@ function braille(columns, rows, phase) {
202
260
  if (detail > 0) circle(earX, earY, earRadius * 0.35, pixel);
203
261
  else disc(earX, earY, pixel * 0.9);
204
262
  // A small eye and nose keep the dog readable before the hatch pattern resolves.
205
- disc(0.22, -0.13, pixel * 1.05, false);
206
- disc(0.53, 0.045, pixel * 0.85);
263
+ disc(eye[0], eye[1], pixel * 1.05, false);
264
+ disc(nose[0], nose[1], pixel * 0.85);
207
265
 
208
- line([[1.17, -1.095], [0.91, -0.8]], Math.max(pixel * 1.8, 0.03));
209
- const cartridge = [[0.92, -0.845], [0.998, -0.777], [0.808, -0.567], [0.73, -0.635]];
266
+ line(arm, Math.max(pixel * 1.8, 0.03));
210
267
  polygon(cartridge, true);
211
268
  line([...cartridge, cartridge[0]], pixel);
212
269
  disc(0.914, -0.764, Math.max(pixel * 0.7, 0.021), false);
@@ -216,16 +273,36 @@ function braille(columns, rows, phase) {
216
273
  const contact = [Math.cos(angle) * 0.928, Math.sin(angle) * 0.928];
217
274
  disc(contact[0], contact[1], pixel * (0.95 + pulse * 0.15));
218
275
 
276
+ return art;
277
+ }
278
+
279
+ function braille(columns, rows, phase, cellAspect) {
280
+ const art = paint(columns, rows, phase, cellAspect);
281
+ const { radius } = art;
219
282
  const bit = [[1, 8], [2, 16], [4, 32], [64, 128]];
220
- return Array.from({ length: rows }, (_, row) => Array.from({ length: columns }, (_, column) => {
221
- let mask = 0;
222
- for (let y = 0; y < 4; y += 1) {
223
- for (let x = 0; x < 2; x += 1) {
224
- if (art.dots[(row * 4 + y) * art.width + column * 2 + x]) mask |= bit[y][x];
283
+ const tones = [];
284
+ const lines = Array.from({ length: rows }, (_, row) => {
285
+ const rowTones = [];
286
+ const line = Array.from({ length: columns }, (_, column) => {
287
+ let mask = 0;
288
+ let tone = 0;
289
+ for (let y = 0; y < 4; y += 1) {
290
+ for (let x = 0; x < 2; x += 1) {
291
+ const value = art.dots[(row * 4 + y) * art.width + column * 2 + x];
292
+ if (value) mask |= bit[y][x];
293
+ tone = Math.max(tone, value);
294
+ }
225
295
  }
226
- }
227
- return mask ? String.fromCodePoint(0x2800 + mask) : " ";
228
- }).join(""));
296
+ rowTones.push(tone);
297
+ return mask ? String.fromCodePoint(0x2800 + mask) : " ";
298
+ }).join("");
299
+ tones.push(rowTones);
300
+ return line;
301
+ });
302
+ // The disc's own center in cells; the tonearm reaches past it, so layouts align on this.
303
+ const stretch = cellAspect / 2;
304
+ const cell = (x, y) => [(art.centerX + x * radius + 0.5) / 2, (art.centerY + y * radius / stretch + 0.5) / 4];
305
+ return { lines, tones, center: cell(0, 0), eye: cell(...eye), radius: [radius / 2, radius / stretch / 4] };
229
306
  }
230
307
 
231
308
  function ascii(columns, rows, phase) {
@@ -252,27 +329,55 @@ function ascii(columns, rows, phase) {
252
329
  if (animated[y]?.[x] !== undefined) animated[y][x] = mark;
253
330
  }
254
331
  }
255
- return Array.from({ length: rows }, (_, row) => {
332
+ const lines = Array.from({ length: rows }, (_, row) => {
256
333
  const content = animated[row - top]?.join("") ?? "";
257
334
  return (" ".repeat(left) + content).slice(0, columns).padEnd(columns);
258
335
  });
336
+ const [centerX, centerY, radiusX, radiusY] = selected?.orbit ?? [width / 2, template.length / 2, width / 2, template.length / 2];
337
+ return {
338
+ lines,
339
+ center: [left + centerX + 0.5, top + centerY + 0.5],
340
+ eye: [left + centerX + eye[0] * radiusX + 0.5, top + centerY + eye[1] * radiusY + 0.5],
341
+ radius: [radiusX, radiusY],
342
+ };
259
343
  }
260
344
 
261
345
  /**
262
346
  * Original terminal drawing, composed from geometry and hand-lettered ASCII.
263
- * Braille uses a 2-by-4 dot grid with square dots for a typical 1:2 terminal cell.
347
+ * Braille uses a 2-by-4 dot grid; cellAspect (cell height over width) keeps the record round,
348
+ * and the default 2 suits a typical 1:2 terminal cell.
264
349
  * phase advances the moon surface and groove highlights through an eight-second loop.
265
350
  * The portrait and cartridge stay fixed; scheduling and motion preferences belong to the caller.
351
+ * Every cell also gets a RECORD_LAYERS tone, so each layer can take its own ink.
266
352
  */
267
- export function renderLunarRecord({ columns = 56, rows = 24, style = "braille", phase = 0 } = {}) {
353
+ export function renderLunarRecordLayers({ columns = 56, rows = 24, style = "braille", phase = 0, cellAspect = 2 } = {}) {
268
354
  const width = Number.isFinite(columns) ? Math.max(0, Math.floor(columns)) : 56;
269
355
  const height = Number.isFinite(rows) ? Math.max(0, Math.floor(rows)) : 24;
270
- if (!width || !height) return Array.from({ length: height }, () => "");
356
+ if (!width || !height) {
357
+ const center = [width / 2, height / 2];
358
+ return { lines: Array.from({ length: height }, () => ""), tones: Array.from({ length: height }, () => []), center, eye: center, radius: center };
359
+ }
271
360
  const frame = Number.isFinite(phase)
272
361
  ? ((Math.floor(phase) % LOGO_MOTION_FRAMES) + LOGO_MOTION_FRAMES) % LOGO_MOTION_FRAMES
273
362
  : 0;
274
- if (style === "ascii" || width < 20 || height < 9) return ascii(width, height, frame);
275
- return braille(width, height, frame);
363
+ if (style === "ascii" || width < 20 || height < 9) {
364
+ // Hand-lettered ASCII has no separable layers; it keeps one ink.
365
+ const { lines, ...geometry } = ascii(width, height, frame);
366
+ return { lines, ...geometry, tones: lines.map((line) => Array.from(line, (character) => character === " " ? 0 : RECORD_LAYERS.figure)) };
367
+ }
368
+ const aspect = Number.isFinite(cellAspect) ? Math.min(3, Math.max(1.5, cellAspect)) : 2;
369
+ return braille(width, height, frame, aspect);
370
+ }
371
+
372
+ /**
373
+ * The same record as dots rather than Braille characters, for surfaces that draw their own dots,
374
+ * such as the desktop window's canvas. Dots sit on a square grid of width x height, row by row;
375
+ * each holds 0 or the RECORD_LAYERS value that inked it.
376
+ */
377
+ export function renderLunarRecordDots({ columns = 56, rows = 28, phase = 0 } = {}) {
378
+ const frame = Number.isFinite(phase) ? ((Math.floor(phase) % LOGO_MOTION_FRAMES) + LOGO_MOTION_FRAMES) % LOGO_MOTION_FRAMES : 0;
379
+ const { dots, width, height } = paint(Math.max(10, Math.floor(columns)), Math.max(5, Math.floor(rows)), frame, 2);
380
+ return { dots, width, height };
276
381
  }
277
382
 
278
383
  /** Five-pixel letterforms, packed into three terminal rows without color or escapes. */