@audiofool/moondog 0.3.0-beta.2 → 0.3.0-beta.4

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.
@@ -1060,4 +1060,62 @@ export default {
1060
1060
  "The next record is still being cut.": "O próximo disco ainda está sendo gravado.",
1061
1061
  "Off air.": "Fora do ar.",
1062
1062
  "Turn on music making first: /generation on.": "Ative a criação de música primeiro: /generation on.",
1063
+ "Download a Stable Audio 3 model from Stability AI": "Baixar um modelo do Stable Audio 3 da Stability AI",
1064
+ "Make music with a downloaded model": "Fazer música com um modelo baixado",
1065
+ "Delete a downloaded model from this Mac": "Apagar deste Mac um modelo baixado",
1066
+ "Stop the download": "Parar o download",
1067
+ "Medium: best sound, about 5.2 GB": "Medium: o melhor som, cerca de 5,2 GB",
1068
+ "Small Music: faster, lighter, about 1.7 GB": "Small Music: mais rápido e leve, cerca de 1,7 GB",
1069
+ "getting ready": "preparando",
1070
+ "There isn't enough free space on this Mac for the download. Free some up, then use /generation download again.": "Não há espaço livre suficiente neste Mac para o download. Libere um pouco e use /generation download de novo.",
1071
+ "A downloaded file didn't match the one Stability AI publishes, so Moondog deleted it. Use /generation download to try again.": "Um arquivo baixado não coincidia com o que a Stability AI publica, então o Moondog o apagou. Use /generation download para tentar de novo.",
1072
+ "The download couldn't reach its source. Check your connection, then use /generation download to try again.": "O download não conseguiu chegar à origem. Verifique sua conexão e use /generation download para tentar de novo.",
1073
+ "Another Moondog window or terminal is already downloading. /generation shows how far it is.": "Outra janela ou terminal do Moondog já está baixando. /generation mostra quanto falta.",
1074
+ "The download didn't finish: {error}. Use /generation download to try again.": "O download não terminou: {error}. Use /generation download para tentar de novo.",
1075
+ "unknown error": "erro desconhecido",
1076
+ "Stable Audio 3 {model} is ready. Ask for a track.": "O Stable Audio 3 {model} está pronto. Peça uma faixa.",
1077
+ "Stable Audio 3 {model} is downloaded. Use it with /generation use {choice}.": "O Stable Audio 3 {model} foi baixado. Use com /generation use {choice}.",
1078
+ "Download stopped. /generation download {choice} picks it up where it left off.": "Download parado. /generation download {choice} continua de onde parou.",
1079
+ "download stopped on an error": "download parado por um erro",
1080
+ "downloading": "baixando",
1081
+ "downloading, {percent}%": "baixando, {percent}%",
1082
+ "installed, in use": "instalado, em uso",
1083
+ "installed": "instalado",
1084
+ "partly downloaded": "baixado em parte",
1085
+ "not downloaded": "não baixado",
1086
+ "{size} GB": "{size} GB",
1087
+ "In use: your own install at {folder}.": "Em uso: sua própria instalação em {folder}.",
1088
+ "Get a model with /generation download medium or small. /generation use, remove and cancel manage them.": "Baixe um modelo com /generation download medium ou small. /generation use, remove e cancel cuidam deles.",
1089
+ "Making music on this Mac is off. Get Stable Audio 3 with /generation download medium, or small for a lighter model; music making turns on when it's done.": "Fazer música neste Mac está desativado. Baixe o Stable Audio 3 com /generation download medium, ou small para um modelo mais leve; a criação de música é ativada ao terminar.",
1090
+ "Stable Audio 3 {model} isn't downloaded yet. Get it with /generation download {choice}.": "O Stable Audio 3 {model} ainda não foi baixado. Baixe com /generation download {choice}.",
1091
+ "Making music on this Mac is off. Stable Audio 3 {model} is ready; turn it on with /generation on.": "Fazer música neste Mac está desativado. O Stable Audio 3 {model} está pronto; ative com /generation on.",
1092
+ "Use /generation download, use or remove with medium or small, or /generation cancel.": "Use /generation download, use ou remove com medium ou small, ou /generation cancel.",
1093
+ "No download is running.": "Nenhum download em andamento.",
1094
+ "Stable Audio 3 {model} is already downloading.": "O Stable Audio 3 {model} já está sendo baixado.",
1095
+ "Another download is running. /generation cancel stops it.": "Há outro download em andamento. /generation cancel o interrompe.",
1096
+ "Stable Audio 3 {model} is already on this Mac. Use it with /generation use {choice}.": "O Stable Audio 3 {model} já está neste Mac. Use com /generation use {choice}.",
1097
+ "Already downloaded.": "Já foi baixado.",
1098
+ "Stable Audio 3 comes under the Stability AI Community License ({license}), and its text encoder, T5Gemma, under the Gemma Terms of Use ({gemma}). Moondog downloads it only from Stability AI's official sources and checks every file.\n\nTo agree and download {model} ({size} GB): /generation download {choice} --agree": "O Stable Audio 3 está sob a Stability AI Community License ({license}), e seu codificador de texto, T5Gemma, sob os Gemma Terms of Use ({gemma}). O Moondog o baixa só das fontes oficiais da Stability AI e confere cada arquivo.\n\nPara aceitar e baixar o {model} ({size} GB): /generation download {choice} --agree",
1099
+ "Waiting for your agreement.": "Aguardando sua aceitação.",
1100
+ "The download didn't finish.": "O download não terminou.",
1101
+ "Downloading Stable Audio 3 {model} ({size} GB) from Stability AI's official sources. It runs in the background; /generation cancel stops it, and a later download picks up where it left off.": "Baixando o Stable Audio 3 {model} ({size} GB) das fontes oficiais da Stability AI. Ele segue em segundo plano; /generation cancel o interrompe, e um download depois continua de onde parou.",
1102
+ "Downloading Stable Audio 3 {model}.": "Baixando o Stable Audio 3 {model}.",
1103
+ "Wait for the download to finish, or stop it with /generation cancel, before removing a model.": "Espere o download terminar, ou pare com /generation cancel, antes de remover um modelo.",
1104
+ "A download is running.": "Há um download em andamento.",
1105
+ "Stable Audio 3 {model} isn't on this Mac.": "O Stable Audio 3 {model} não está neste Mac.",
1106
+ "Removing Stable Audio 3 {model} deletes its {size} GB from this Mac. It's the last model, so the rest of Moondog's Stable Audio 3 setup goes too. To go ahead: /generation remove {choice} --yes": "Remover o Stable Audio 3 {model} apaga seus {size} GB deste Mac. É o último modelo, então o resto da instalação do Stable Audio 3 do Moondog também sai. Para continuar: /generation remove {choice} --yes",
1107
+ "Removing Stable Audio 3 {model} deletes its {size} GB from this Mac. To go ahead: /generation remove {choice} --yes": "Remover o Stable Audio 3 {model} apaga seus {size} GB deste Mac. Para continuar: /generation remove {choice} --yes",
1108
+ "Nothing removed yet.": "Nada foi removido ainda.",
1109
+ "Removed Stable Audio 3 {model} from this Mac.": "Stable Audio 3 {model} removido deste Mac.",
1110
+ "Removed.": "Removido.",
1111
+ "Removed. Music making isn't ready.": "Removido. A criação de música não está pronta.",
1112
+ "Use /generation, /generation on [folder] or off, or /generation download, use or remove with medium or small.": "Use /generation, /generation on [pasta] ou off, ou /generation download, use ou remove com medium ou small.",
1113
+ "best sound": "o melhor som",
1114
+ "faster, lighter": "mais rápido e leve",
1115
+ "getting uv": "obtendo o uv",
1116
+ "getting Stable Audio 3's code": "obtendo o código do Stable Audio 3",
1117
+ "setting up Python": "preparando o Python",
1118
+ "downloading the model": "baixando o modelo",
1119
+ "checking the files": "conferindo os arquivos",
1120
+ "Stable Audio 3's Python environment couldn't be set up. Check your connection, then use /generation download to try again.": "Não foi possível preparar o ambiente Python do Stable Audio 3. Verifique sua conexão e use /generation download para tentar de novo.",
1063
1121
  };
@@ -1060,4 +1060,62 @@ export default {
1060
1060
  "The next record is still being cut.": "下一张还在刻。",
1061
1061
  "Off air.": "已停播。",
1062
1062
  "Turn on music making first: /generation on.": "先开启音乐生成:/generation on。",
1063
+ "Download a Stable Audio 3 model from Stability AI": "从 Stability AI 下载一个 Stable Audio 3 模型",
1064
+ "Make music with a downloaded model": "用已下载的模型做音乐",
1065
+ "Delete a downloaded model from this Mac": "从这台 Mac 删除一个已下载的模型",
1066
+ "Stop the download": "停止下载",
1067
+ "Medium: best sound, about 5.2 GB": "Medium:音质最好,约 5.2 GB",
1068
+ "Small Music: faster, lighter, about 1.7 GB": "Small Music:更快更轻,约 1.7 GB",
1069
+ "getting ready": "准备中",
1070
+ "There isn't enough free space on this Mac for the download. Free some up, then use /generation download again.": "这台 Mac 的可用空间不够下载。腾出一些空间,再用一次 /generation download。",
1071
+ "A downloaded file didn't match the one Stability AI publishes, so Moondog deleted it. Use /generation download to try again.": "有个下载的文件和 Stability AI 发布的不一致,Moondog 已把它删除。用 /generation download 重试。",
1072
+ "The download couldn't reach its source. Check your connection, then use /generation download to try again.": "下载连不上来源。检查网络连接,再用 /generation download 重试。",
1073
+ "Another Moondog window or terminal is already downloading. /generation shows how far it is.": "另一个 Moondog 窗口或终端已经在下载。/generation 可以看进度。",
1074
+ "The download didn't finish: {error}. Use /generation download to try again.": "下载没有完成:{error}。用 /generation download 重试。",
1075
+ "unknown error": "未知错误",
1076
+ "Stable Audio 3 {model} is ready. Ask for a track.": "Stable Audio 3 {model} 已就绪。想听就让它做一首。",
1077
+ "Stable Audio 3 {model} is downloaded. Use it with /generation use {choice}.": "Stable Audio 3 {model} 已下载。用 /generation use {choice} 来使用它。",
1078
+ "Download stopped. /generation download {choice} picks it up where it left off.": "下载已停止。/generation download {choice} 会从停下的地方接着下。",
1079
+ "download stopped on an error": "下载因错误停止",
1080
+ "downloading": "下载中",
1081
+ "downloading, {percent}%": "下载中,{percent}%",
1082
+ "installed, in use": "已安装,使用中",
1083
+ "installed": "已安装",
1084
+ "partly downloaded": "已下载一部分",
1085
+ "not downloaded": "未下载",
1086
+ "{size} GB": "{size} GB",
1087
+ "In use: your own install at {folder}.": "使用中:你自己在 {folder} 的安装。",
1088
+ "Get a model with /generation download medium or small. /generation use, remove and cancel manage them.": "用 /generation download medium 或 small 获取模型。/generation use、remove 和 cancel 用来管理它们。",
1089
+ "Making music on this Mac is off. Get Stable Audio 3 with /generation download medium, or small for a lighter model; music making turns on when it's done.": "在这台 Mac 上做音乐已关闭。用 /generation download medium 获取 Stable Audio 3,想要更轻的模型就用 small;下载完成后会开启音乐生成。",
1090
+ "Stable Audio 3 {model} isn't downloaded yet. Get it with /generation download {choice}.": "Stable Audio 3 {model} 还没下载。用 /generation download {choice} 获取。",
1091
+ "Making music on this Mac is off. Stable Audio 3 {model} is ready; turn it on with /generation on.": "在这台 Mac 上做音乐已关闭。Stable Audio 3 {model} 已准备好,用 /generation on 开启。",
1092
+ "Use /generation download, use or remove with medium or small, or /generation cancel.": "用 /generation download、use 或 remove 加上 medium 或 small,或用 /generation cancel。",
1093
+ "No download is running.": "没有正在进行的下载。",
1094
+ "Stable Audio 3 {model} is already downloading.": "Stable Audio 3 {model} 已经在下载了。",
1095
+ "Another download is running. /generation cancel stops it.": "另一个下载正在进行。/generation cancel 可以停止它。",
1096
+ "Stable Audio 3 {model} is already on this Mac. Use it with /generation use {choice}.": "Stable Audio 3 {model} 已经在这台 Mac 上。用 /generation use {choice} 来使用它。",
1097
+ "Already downloaded.": "已经下载过了。",
1098
+ "Stable Audio 3 comes under the Stability AI Community License ({license}), and its text encoder, T5Gemma, under the Gemma Terms of Use ({gemma}). Moondog downloads it only from Stability AI's official sources and checks every file.\n\nTo agree and download {model} ({size} GB): /generation download {choice} --agree": "Stable Audio 3 适用 Stability AI Community License({license}),它的文本编码器 T5Gemma 适用 Gemma Terms of Use({gemma})。Moondog 只从 Stability AI 的官方来源下载,并校验每个文件。\n\n同意并下载 {model}({size} GB):/generation download {choice} --agree",
1099
+ "Waiting for your agreement.": "等你同意。",
1100
+ "The download didn't finish.": "下载没有完成。",
1101
+ "Downloading Stable Audio 3 {model} ({size} GB) from Stability AI's official sources. It runs in the background; /generation cancel stops it, and a later download picks up where it left off.": "正在从 Stability AI 的官方来源下载 Stable Audio 3 {model}({size} GB)。它在后台进行;/generation cancel 可以停止,之后再下载会从停下的地方接着下。",
1102
+ "Downloading Stable Audio 3 {model}.": "正在下载 Stable Audio 3 {model}。",
1103
+ "Wait for the download to finish, or stop it with /generation cancel, before removing a model.": "删除模型前,先等下载完成,或用 /generation cancel 停止它。",
1104
+ "A download is running.": "有下载正在进行。",
1105
+ "Stable Audio 3 {model} isn't on this Mac.": "这台 Mac 上没有 Stable Audio 3 {model}。",
1106
+ "Removing Stable Audio 3 {model} deletes its {size} GB from this Mac. It's the last model, so the rest of Moondog's Stable Audio 3 setup goes too. To go ahead: /generation remove {choice} --yes": "删除 Stable Audio 3 {model} 会从这台 Mac 删掉它的 {size} GB。这是最后一个模型,所以 Moondog 的 Stable Audio 3 其余部分也会一并删除。确定的话:/generation remove {choice} --yes",
1107
+ "Removing Stable Audio 3 {model} deletes its {size} GB from this Mac. To go ahead: /generation remove {choice} --yes": "删除 Stable Audio 3 {model} 会从这台 Mac 删掉它的 {size} GB。确定的话:/generation remove {choice} --yes",
1108
+ "Nothing removed yet.": "还没有删除任何东西。",
1109
+ "Removed Stable Audio 3 {model} from this Mac.": "已从这台 Mac 删除 Stable Audio 3 {model}。",
1110
+ "Removed.": "已删除。",
1111
+ "Removed. Music making isn't ready.": "已删除。音乐生成还没准备好。",
1112
+ "Use /generation, /generation on [folder] or off, or /generation download, use or remove with medium or small.": "用 /generation、/generation on [文件夹] 或 off,或用 /generation download、use 或 remove 加上 medium 或 small。",
1113
+ "best sound": "音质最好",
1114
+ "faster, lighter": "更快更轻",
1115
+ "getting uv": "正在获取 uv",
1116
+ "getting Stable Audio 3's code": "正在获取 Stable Audio 3 的代码",
1117
+ "setting up Python": "正在准备 Python",
1118
+ "downloading the model": "正在下载模型",
1119
+ "checking the files": "正在校验文件",
1120
+ "Stable Audio 3's Python environment couldn't be set up. Check your connection, then use /generation download to try again.": "没能准备好 Stable Audio 3 的 Python 环境。检查网络连接,再用 /generation download 重试。",
1063
1121
  };
@@ -7,6 +7,8 @@ import { access, constants, rm, stat, writeFile } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import path from "node:path";
9
9
 
10
+ import { createPlayerNow } from "../../core/listening-now.mjs";
11
+
10
12
  // Players that play one file without a window and exit at its end, in order of preference.
11
13
  // None of them may read or write the terminal: the listening room owns it.
12
14
  const systemPlayers = {
@@ -69,8 +71,11 @@ export async function createTerminalPreviewPlayer({
69
71
  let queue = null;
70
72
  let child = null;
71
73
  let closed = false;
74
+ // What it plays, for the agent: the queue it holds, moved along by its own events.
75
+ const now = createPlayerNow();
72
76
 
73
77
  const emit = (event) => {
78
+ now.observe(event);
74
79
  for (const listener of listeners) {
75
80
  try { listener(event); } catch {}
76
81
  }
@@ -223,6 +228,7 @@ export async function createTerminalPreviewPlayer({
223
228
  if (closed || !name || typeof file !== "string" || !path.isAbsolute(file)) return;
224
229
  if (!queue || !queue.tracks.every((track) => track.generated)) return this.playGenerated({ id, title, file });
225
230
  queue.tracks.push({ title: name, artist_credit: "", generated: true, id: String(id ?? ""), file });
231
+ now.appended({ title: name, generated: true });
226
232
  },
227
233
  // Ends the song now playing, or still loading; the next one starts after it.
228
234
  skip() {
@@ -232,6 +238,8 @@ export async function createTerminalPreviewPlayer({
232
238
  return true;
233
239
  },
234
240
  stop,
241
+ // What plays now, as createPlayerNow reads it: null, or { state, song, position, count, next }.
242
+ nowPlaying: () => (closed ? null : now.read()),
235
243
  // Leaving Apple Music stops its previews here, never the tracks Moondog made.
236
244
  holdsCatalogSongs: () => Boolean(queue?.tracks.some((track) => !track.generated)),
237
245
  subscribe(listener) {
@@ -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
+ }