@audiofool/moondog 0.2.1 → 0.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/NOTICE +12 -0
  2. package/README.md +3 -2
  3. package/licenses/stability-ai-community-license.md +58 -0
  4. package/package.json +3 -1
  5. package/scripts/moondog.mjs +38 -2
  6. package/src/core/apple-projection-domain-services.mjs +14 -0
  7. package/src/core/capability-catalog.mjs +55 -0
  8. package/src/core/listening-profile-domain-services.mjs +12 -0
  9. package/src/core/moondog-application.mjs +195 -2
  10. package/src/i18n/messages/de.mjs +2 -0
  11. package/src/i18n/messages/en.mjs +2 -0
  12. package/src/i18n/messages/es.mjs +2 -0
  13. package/src/i18n/messages/fr.mjs +2 -0
  14. package/src/i18n/messages/it.mjs +2 -0
  15. package/src/i18n/messages/ja.mjs +2 -0
  16. package/src/i18n/messages/ko.mjs +2 -0
  17. package/src/i18n/messages/pt.mjs +2 -0
  18. package/src/i18n/messages/zh-Hant.mjs +2 -0
  19. package/src/i18n/messages/zh.mjs +2 -0
  20. package/src/i18n/screen/es.mjs +26 -0
  21. package/src/i18n/screen/ja.mjs +26 -0
  22. package/src/i18n/screen/pt.mjs +26 -0
  23. package/src/i18n/screen/zh.mjs +26 -0
  24. package/src/integrations/apple-music/terminal-preview-player.mjs +38 -11
  25. package/src/integrations/spotify/authentication.mjs +23 -18
  26. package/src/integrations/stable-audio/local-generator.mjs +683 -0
  27. package/src/profile/moondog-suggestions.mjs +9 -0
  28. package/src/profile/taste-compiler.mjs +197 -0
  29. package/src/runtime/pi/agent-runtime.mjs +278 -9
  30. package/src/runtime/pi/persistent-credential-store.mjs +6 -28
  31. package/src/surfaces/cli/command-completions.mjs +1 -0
  32. package/src/surfaces/cli/format-output.mjs +1 -0
  33. package/src/surfaces/cli/tui.mjs +70 -8
@@ -0,0 +1,683 @@
1
+ // Makes music on this Mac with a local Stable Audio 3 install (Stability AI's optimized MLX
2
+ // build). The listener turns it on once, in Settings or with /generation; until then nothing
3
+ // runs. Each track is a WAV and a receipt in Moondog's state directory, and nothing leaves the
4
+ // machine: weights are never downloaded here, and the receipt keeps no absolute path.
5
+ import { spawn } from "node:child_process";
6
+ import { randomBytes, randomInt, randomUUID } from "node:crypto";
7
+ import { unwatchFile, watchFile } from "node:fs";
8
+ import { access, appendFile, chmod, constants, mkdir, open, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import path from "node:path";
11
+
12
+ import { resolveMoondogStateDirectory } from "../../core/state-directory.mjs";
13
+ import { streamVariation } from "../../profile/taste-compiler.mjs";
14
+ import { resolveMoondogAuthFile } from "../../runtime/pi/persistent-credential-store.mjs";
15
+
16
+ // The weights Stable Audio 3 Medium needs for text to music: the text encoder, the model and its decoder.
17
+ const MEDIUM_WEIGHTS = ["t5gemma_f16.npz", "dit_medium_f16.npz", "same_l_decoder_f32.npz"];
18
+ // Medium was trained on up to 380 seconds; very short clips come out rough.
19
+ export const GENERATION_SECONDS = Object.freeze({ min: 15, max: 380, default: 120 });
20
+ // A cold start reads about 4.8 GB of weights before the first step; a 380-second track then takes about 35 seconds.
21
+ const generationTimeoutMs = 10 * 60_000;
22
+ const stoppingGraceMs = 3_000;
23
+ const trackIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
24
+
25
+ export const isGeneratedTrackId = (value) => typeof value === "string" && trackIdPattern.test(value);
26
+
27
+ /** Where Stability's own install script puts the MLX build when it runs from ~/Developer. */
28
+ export function defaultStableAudioRoot() {
29
+ return path.join(homedir(), "Developer", "stable-audio-3", "optimized", "mlx");
30
+ }
31
+
32
+ // Kept next to the playback preference, apart from runtime settings.
33
+ export function resolveGenerationSettingsFile(environment = process.env) {
34
+ return path.join(path.dirname(resolveMoondogAuthFile(environment)), "generation.json");
35
+ }
36
+
37
+ /** The generated tracks: one folder per track, named by its id. */
38
+ export function resolveGeneratedTracksDirectory(environment = process.env) {
39
+ return path.join(resolveMoondogStateDirectory(environment), "generated");
40
+ }
41
+
42
+ // A typed folder: absolute, or under the home folder with ~/.
43
+ function literalFolder(value) {
44
+ const text = typeof value === "string" ? value.trim() : "";
45
+ if (!text) return null;
46
+ const expanded = text === "~" ? homedir() : text.startsWith("~/") ? path.join(homedir(), text.slice(2)) : text;
47
+ return path.isAbsolute(expanded) ? path.resolve(expanded) : null;
48
+ }
49
+
50
+ /**
51
+ * The listener's choice: { enabled, path, source }. Generation is off until they turn it on;
52
+ * MOONDOG_SA3_DIR can point at another install but never turns it on.
53
+ * A missing or unreadable file reads as off, so an older or newer file never stops the room.
54
+ */
55
+ export async function readGenerationSettings(environment = process.env) {
56
+ let document = null;
57
+ try {
58
+ document = JSON.parse(await readFile(resolveGenerationSettingsFile(environment), "utf8"));
59
+ } catch { /* Not set up yet. */ }
60
+ const saved = document?.version === 1 ? document : {};
61
+ const fromEnvironment = literalFolder(environment.MOONDOG_SA3_DIR);
62
+ const fromFile = literalFolder(saved.path);
63
+ return {
64
+ enabled: saved.enabled === true,
65
+ path: fromEnvironment ?? fromFile ?? defaultStableAudioRoot(),
66
+ source: fromEnvironment ? "environment" : fromFile ? "settings" : "default",
67
+ };
68
+ }
69
+
70
+ export async function writeGenerationSettings({ enabled, path: folder } = {}, environment = process.env) {
71
+ const current = await readGenerationSettings(environment);
72
+ const typed = folder === undefined ? undefined : literalFolder(folder);
73
+ if (folder !== undefined && folder !== null && !typed) {
74
+ throw Object.assign(new Error("Give the Stable Audio 3 folder as a full path, or one starting with ~/."), { code: "generation_path_invalid" });
75
+ }
76
+ const document = {
77
+ version: 1,
78
+ enabled: enabled === undefined ? current.enabled : enabled === true,
79
+ ...(typed ? { path: typed } : current.source === "settings" ? { path: current.path } : {}),
80
+ };
81
+ const file = resolveGenerationSettingsFile(environment);
82
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
83
+ await writeFile(file, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
84
+ return readGenerationSettings(environment);
85
+ }
86
+
87
+ const isFile = async (file, { executable = false, minimumBytes = 0 } = {}) => {
88
+ try {
89
+ // stat follows the install's symlinks into the Hugging Face cache, so a cleared cache reads as missing.
90
+ const info = await stat(file);
91
+ if (!info.isFile() || info.size < minimumBytes) return false;
92
+ if (executable) await access(file, constants.X_OK);
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ };
98
+
99
+ /**
100
+ * Whether an install can make music now, without running it: "ready", "unsupported" (MLX needs
101
+ * Apple silicon), "missing" (no install at the folder) or "incomplete" (no Python environment
102
+ * or no Medium weights, which Stability's ./install.sh fetches).
103
+ */
104
+ export async function checkStableAudioInstall(root, { platform = process.platform, arch = process.arch } = {}) {
105
+ if (platform !== "darwin" || arch !== "arm64") return { state: "unsupported" };
106
+ if (!root || !path.isAbsolute(root)) return { state: "missing" };
107
+ if (!(await isFile(path.join(root, "scripts", "sa3_mlx.py")))) return { state: "missing" };
108
+ if (!(await isFile(path.join(root, ".venv", "bin", "python"), { executable: true }))) return { state: "incomplete", missing: "environment" };
109
+ for (const weight of MEDIUM_WEIGHTS) {
110
+ if (!(await isFile(path.join(root, "models", "mlx", weight), { minimumBytes: 1024 }))) return { state: "incomplete", missing: "weights" };
111
+ }
112
+ return { state: "ready" };
113
+ }
114
+
115
+ // A made track the listener left within this many seconds was skipped; later, stopped.
116
+ const SKIP_SECONDS = 30;
117
+ const OUTCOMES = Object.freeze(["completed", "skipped", "stopped", "replayed"]);
118
+
119
+ // The stage lines the script always prints, which are the only progress a pipe receives.
120
+ const STAGES = new Map([["1", "reading"], ["2", "reading"], ["3", "composing"], ["4", "decoding"], ["5", "writing"]]);
121
+ const doneLine = /done\s+([\d.]+)s\s+wall\s+\S+\s+([\d.]+)s\s+audio.*?peak\s+RAM\s+([\d.]+)\s+(MB|GB)/u;
122
+
123
+ const generationError = (code, message) => Object.assign(new Error(message), { code });
124
+
125
+ // Reads a WAV header, so a receipt states what was written and a truncated file never plays.
126
+ async function wavInfo(file) {
127
+ const handle = await open(file, "r");
128
+ try {
129
+ const header = Buffer.alloc(44);
130
+ const { bytesRead } = await handle.read(header, 0, 44, 0);
131
+ const size = (await handle.stat()).size;
132
+ if (bytesRead < 44 || header.toString("ascii", 0, 4) !== "RIFF" || header.toString("ascii", 8, 12) !== "WAVE") return null;
133
+ const channels = header.readUInt16LE(22);
134
+ const sampleRate = header.readUInt32LE(24);
135
+ const bitDepth = header.readUInt16LE(34);
136
+ const bytesPerSecond = sampleRate * channels * (bitDepth / 8);
137
+ if (!channels || !sampleRate || !bytesPerSecond) return null;
138
+ return { sample_rate: sampleRate, channels, bit_depth: bitDepth, size_bytes: size,
139
+ duration_seconds: Math.round(((size - 44) / bytesPerSecond) * 100) / 100 };
140
+ } finally {
141
+ await handle.close();
142
+ }
143
+ }
144
+
145
+ const capitalized = (value) => value.replace(/(^|[\s/-])(\p{L})/gu, (_, edge, letter) => `${edge}${letter.toUpperCase()}`);
146
+ const sentence = (value) => value ? value[0].toUpperCase() + value.slice(1) : value;
147
+
148
+ /**
149
+ * Stable Audio 3's prompt for a compiled taste, in the tag form Stability's own prompt assistant
150
+ * writes: instrumental first, then genres, instruments, and the sound in plain words, then the
151
+ * tempo. The length is not prompt text; it goes to the model as its own setting.
152
+ */
153
+ export function stableAudioPrompt({ descriptors = [], bpm = null } = {}) {
154
+ const terms = (facet) => descriptors.filter((descriptor) => descriptor.facet === facet).map((descriptor) => descriptor.term);
155
+ const tags = ["TrackType: Music", "VocalType: Instrumental", ...terms("genre").map((genre) => `Genre: ${capitalized(genre)}`)];
156
+ const instruments = terms("instrument");
157
+ if (instruments.length) tags.push(`Instruments: ${instruments.map(capitalized).join(", ")}`);
158
+ const sound = [...terms("texture"), ...terms("rhythm"), ...terms("mood")];
159
+ return [tags.join(", "), sound.length ? sentence(sound.join(", ")) : null, Number.isInteger(bpm) ? `${bpm} BPM` : null]
160
+ .filter(Boolean).join(". ").concat(".");
161
+ }
162
+
163
+ /**
164
+ * The one generator a room shares. status() is cached so the agent can ask on every tool call;
165
+ * check() reads the settings and the install again. One track is made at a time.
166
+ */
167
+ export function createLocalGenerator({
168
+ environment = process.env,
169
+ platform = process.platform,
170
+ arch = process.arch,
171
+ spawnImpl = spawn,
172
+ now = () => new Date(),
173
+ } = {}) {
174
+ let current = { state: "unknown", enabled: false, install: "unknown", path: null, source: null };
175
+ let running = null;
176
+ // The tracks made here, newest last, with what happened each time they played: the loop back
177
+ // from listening reads them. Each track's outcomes.jsonl keeps them; the receipt never changes.
178
+ const made = new Map();
179
+ // The made track playing now in this room's player: { id, heardMs, since }, since null while paused.
180
+ let playing = null;
181
+ // Outcome writes still on their way, which a closing room waits for.
182
+ const writing = new Set();
183
+ let checking = null;
184
+ const listeners = new Set();
185
+ const tracksDirectory = () => resolveGeneratedTracksDirectory(environment);
186
+
187
+ const publish = (next) => {
188
+ const changed = JSON.stringify(next) !== JSON.stringify(current);
189
+ current = next;
190
+ if (changed) for (const listener of listeners) {
191
+ try { listener(structuredClone(current)); } catch { /* A surface's redraw cannot undo the check. */ }
192
+ }
193
+ return structuredClone(current);
194
+ };
195
+
196
+ const check = () => {
197
+ checking ??= (async () => {
198
+ try {
199
+ const settings = await readGenerationSettings(environment);
200
+ const install = await checkStableAudioInstall(settings.path, { platform, arch });
201
+ return publish({
202
+ state: !settings.enabled ? "off" : install.state,
203
+ enabled: settings.enabled,
204
+ install: install.state,
205
+ ...(install.missing ? { missing: install.missing } : {}),
206
+ path: settings.path,
207
+ source: settings.source,
208
+ });
209
+ } finally {
210
+ checking = null;
211
+ }
212
+ })();
213
+ return checking;
214
+ };
215
+
216
+ // Folders a crashed run left behind. The terminal and the window each make tracks here, so a
217
+ // folder whose process still runs is that surface's track in the making and stays.
218
+ const alive = (pid) => {
219
+ try {
220
+ process.kill(pid, 0);
221
+ return true;
222
+ } catch (error) {
223
+ return error?.code === "EPERM";
224
+ }
225
+ };
226
+ const sweep = async () => {
227
+ const folder = tracksDirectory();
228
+ let names = [];
229
+ try { names = await readdir(folder); } catch { return; }
230
+ await Promise.all(names.map((name) => [name, Number(/^\.[0-9a-f-]{36}\.tmp-(\d+)-/u.exec(name)?.[1])])
231
+ .filter(([, pid]) => Number.isInteger(pid) && pid > 0 && pid !== process.pid && !alive(pid))
232
+ .map(([name]) => rm(path.join(folder, name), { recursive: true, force: true })));
233
+ };
234
+
235
+ const stopped = () => Object.assign(new Error("Making the track was stopped."), { name: "AbortError", code: "generation_stopped" });
236
+ const run = ({ root, prompt, seconds, seed, out }, { signal, onProgress }) => new Promise((resolve, reject) => {
237
+ // A stop that came while the install was checked starts nothing.
238
+ if (signal?.aborted) return reject(stopped());
239
+ // The Python script itself, not Stability's ./sa3 wrapper: the wrapper wants uv on PATH and a
240
+ // terminal, and its exec would leave a shell between this process and the one to stop.
241
+ const child = spawnImpl(path.join(root, ".venv", "bin", "python"), [
242
+ "scripts/sa3_mlx.py", "--prompt", prompt, "--dit", "medium", "--decoder", "same-l",
243
+ "--seconds", String(seconds), "--seed", String(seed), "--steps", "8", "--out", out,
244
+ ], {
245
+ cwd: root,
246
+ // Every choice is passed, so the script never waits on stdin for a missing one.
247
+ stdio: ["ignore", "pipe", "pipe"],
248
+ // Unbuffered, so stage lines arrive as they happen; offline, so missing weights fail instead of downloading.
249
+ env: { ...environment, PYTHONUNBUFFERED: "1", HF_HUB_OFFLINE: "1" },
250
+ });
251
+ let output = "";
252
+ let measured = null;
253
+ let settled = false;
254
+ let stopping = null;
255
+ // Why a run was stopped, reported once its process has really exited, so the slot stays taken until then.
256
+ let stopReason = null;
257
+ const finish = (error, value) => {
258
+ if (settled) return;
259
+ settled = true;
260
+ clearTimeout(timer);
261
+ signal?.removeEventListener("abort", stop);
262
+ if (error) reject(error);
263
+ else resolve(value);
264
+ };
265
+ const kill = (reason) => {
266
+ if (settled || stopReason) return;
267
+ stopReason = reason;
268
+ clearTimeout(timer);
269
+ child.kill("SIGTERM");
270
+ // The script handles only Ctrl-C, so a run that ignores the request is ended.
271
+ stopping = setTimeout(() => child.kill("SIGKILL"), stoppingGraceMs);
272
+ };
273
+ const stop = () => kill(stopped());
274
+ const timer = setTimeout(() => kill(generationError("generation_timed_out", "Stable Audio 3 took too long, so the track was stopped.")), generationTimeoutMs);
275
+ signal?.addEventListener("abort", stop, { once: true });
276
+ child.stdout.setEncoding("utf8");
277
+ child.stdout.on("data", (chunk) => {
278
+ output += chunk;
279
+ const lines = output.split("\n");
280
+ output = lines.pop();
281
+ for (const line of lines) {
282
+ const stage = /\[(\d)\/5\]/u.exec(line)?.[1];
283
+ if (stage && STAGES.has(stage)) onProgress?.({ stage: STAGES.get(stage) });
284
+ const done = doneLine.exec(line);
285
+ if (done) {
286
+ const peak = Number(done[3]) * (done[4] === "GB" ? 1024 : 1);
287
+ measured = { wall_seconds: Number(done[1]), audio_seconds: Number(done[2]), peak_memory_mb: Math.round(peak) };
288
+ }
289
+ }
290
+ });
291
+ // Its errors can carry the output path and tracebacks; only the exit code is reported.
292
+ child.stderr.resume();
293
+ child.once("error", () => finish(stopReason ?? generationError("generation_failed", "Stable Audio 3 could not be started.")));
294
+ child.once("exit", (code) => {
295
+ clearTimeout(stopping);
296
+ if (stopReason) finish(stopReason);
297
+ else if (code === 0) finish(null, measured);
298
+ else finish(generationError("generation_failed", "Stable Audio 3 stopped without making the track."));
299
+ });
300
+ });
301
+
302
+ const remember = (receipt, outcomes = [], audioKept = true) => {
303
+ if (!isGeneratedTrackId(receipt?.generated_track_id)) return;
304
+ made.set(receipt.generated_track_id, {
305
+ id: receipt.generated_track_id,
306
+ title: String(receipt.title ?? ""),
307
+ made_at: receipt.created_at,
308
+ seconds: receipt.audio?.duration_seconds ?? receipt.generator?.seconds ?? null,
309
+ sound: (Array.isArray(receipt.shaped_by) ? receipt.shaped_by : []).map((entry) => entry?.term).filter((term) => typeof term === "string").slice(0, 8),
310
+ ...(receipt.stream ? { stream: true } : {}),
311
+ // A stream keeps only its newest tracks' audio; the agent must not offer the rest again.
312
+ ...(audioKept ? {} : { audio_kept: false }),
313
+ outcomes,
314
+ });
315
+ };
316
+ // Appends one outcome to the track's own file, so the terminal and the window add to one record.
317
+ const recordOutcome = async (id, outcome) => {
318
+ if (!isGeneratedTrackId(id) || !OUTCOMES.includes(outcome?.outcome)) return;
319
+ const entry = { ...outcome, at: now().toISOString() };
320
+ made.get(id)?.outcomes.push(entry);
321
+ try {
322
+ await appendFile(path.join(tracksDirectory(), id, "outcomes.jsonl"), `${JSON.stringify(entry)}\n`, { encoding: "utf8", mode: 0o600 });
323
+ } catch { /* The track was removed; its outcome goes with it. */ }
324
+ };
325
+ const close = (finished) => {
326
+ if (!playing) return;
327
+ const heard = Math.round((playing.heardMs + (playing.since === null ? 0 : Date.now() - playing.since)) / 1000);
328
+ const { id } = playing;
329
+ playing = null;
330
+ const write = recordOutcome(id, { outcome: finished ? "completed" : heard < SKIP_SECONDS ? "skipped" : "stopped", heard_seconds: heard });
331
+ writing.add(write);
332
+ void write.finally(() => writing.delete(write));
333
+ };
334
+
335
+ return {
336
+ status: () => structuredClone(current),
337
+ /**
338
+ * Reads the tracks made here, with their outcomes, from their folders. The terminal and the
339
+ * window each keep a generator on one folder, so each reply reads it again and sees the other's.
340
+ */
341
+ async load() {
342
+ let names = [];
343
+ try { names = await readdir(tracksDirectory()); } catch { names = []; }
344
+ made.clear();
345
+ for (const name of names.filter(isGeneratedTrackId)) {
346
+ try {
347
+ const receipt = JSON.parse(await readFile(path.join(tracksDirectory(), name, "receipt.json"), "utf8"));
348
+ const outcomes = (await readFile(path.join(tracksDirectory(), name, "outcomes.jsonl"), "utf8").catch(() => ""))
349
+ .split("\n").filter(Boolean).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
350
+ const audioKept = await stat(path.join(tracksDirectory(), name, "track.wav")).then(() => true, () => false);
351
+ remember(receipt, outcomes, audioKept);
352
+ } catch { /* A folder without a readable receipt is not a track. */ }
353
+ }
354
+ },
355
+ /**
356
+ * The newest made tracks and how each landed, for the agent: what Moondog made, never the
357
+ * listener's taste. Each outcome counts plays that ran to the end, were skipped within 30
358
+ * seconds, were stopped later, or that the listener asked for again by name. Moondog's own
359
+ * replays are counted apart, so its picks never read as the listener's.
360
+ */
361
+ recentTracks(limit = 5) {
362
+ const listeners = (entry) => entry.outcome !== "replayed" || entry.by === "listener";
363
+ return [...made.values()].sort((left, right) => String(right.made_at).localeCompare(String(left.made_at))).slice(0, limit)
364
+ .map(({ outcomes, ...track }) => {
365
+ const theirs = outcomes.filter(listeners);
366
+ const counts = Object.fromEntries(OUTCOMES.map((kind) => [kind === "replayed" ? "asked_for_again" : kind, theirs.filter((entry) => entry.outcome === kind).length]));
367
+ return { ...track, outcomes: { ...counts, replayed_by_moondog: outcomes.length - theirs.length },
368
+ ...(theirs.length ? { last_outcome: theirs.at(-1).outcome === "replayed" ? "asked_for_again" : theirs.at(-1).outcome } : {}) };
369
+ });
370
+ },
371
+ /** What a room's player reports. A made track that plays to its end was completed; one that stops or gives way was skipped or stopped. */
372
+ observe(event) {
373
+ const track = event?.track;
374
+ if (event?.type === "playing" && track?.generated === true && isGeneratedTrackId(track.id)) {
375
+ if (playing?.id !== track.id) {
376
+ close(false);
377
+ playing = { id: track.id, heardMs: 0, since: Date.now() };
378
+ } else if (playing.since === null) playing.since = Date.now();
379
+ } else if (event?.type === "paused" && playing && playing.since !== null) {
380
+ playing.heardMs += Date.now() - playing.since;
381
+ playing.since = null;
382
+ } else if (event?.type === "finished" && playing && (!track?.id || track.id === playing.id)) {
383
+ close(true);
384
+ } else if (event?.type === "failed" && playing && (!track?.id || track.id === playing.id)) {
385
+ // A track the player could not play says nothing about the listener.
386
+ playing = null;
387
+ } else if (event?.type === "stopped" || (event?.type === "queue" && !(event.tracks ?? []).some((entry) => entry?.id === playing?.id))) {
388
+ close(false);
389
+ }
390
+ },
391
+ /** Waits for the outcomes still being written, as a room does before it closes. */
392
+ flush: () => Promise.allSettled([...writing]),
393
+ // A replay of the track playing now starts it over; that is not a skip.
394
+ // A stream keeps only its newest tracks' audio; the receipt and outcomes of the rest stay.
395
+ async removeAudio(id) {
396
+ if (!isGeneratedTrackId(id)) return;
397
+ await rm(path.join(tracksDirectory(), id, "track.wav"), { force: true }).catch(() => {});
398
+ const entry = made.get(id);
399
+ if (entry) entry.audio_kept = false;
400
+ },
401
+ restart(id) {
402
+ if (playing?.id === id) playing = null;
403
+ },
404
+ // A play Moondog cut off as it began, as a stream past its time limit does, says nothing about the listener.
405
+ discard(id) {
406
+ if (playing?.id === id) playing = null;
407
+ },
408
+ recordReplay(id, by) {
409
+ this.restart(id);
410
+ return recordOutcome(id, { outcome: "replayed", by: by === "listener" ? "listener" : "moondog" });
411
+ },
412
+ ready: () => current.state === "ready",
413
+ busy: () => running !== null,
414
+ check,
415
+ async save(settings) {
416
+ await writeGenerationSettings(settings, environment);
417
+ return check();
418
+ },
419
+ subscribe(listener) {
420
+ listeners.add(listener);
421
+ return () => listeners.delete(listener);
422
+ },
423
+ // The terminal and the window each run a generator on one settings file. A change saved on one
424
+ // side is checked again on the other; listeners hear it through subscribe.
425
+ watch({ interval = 2000 } = {}) {
426
+ const file = resolveGenerationSettingsFile(environment);
427
+ const listener = () => { void check().catch(() => {}); };
428
+ watchFile(file, { interval, persistent: false }, listener);
429
+ return () => unwatchFile(file, listener);
430
+ },
431
+ sweep,
432
+ /** The WAV of one generated track, or null when there is none. */
433
+ async trackFile(id) {
434
+ if (!isGeneratedTrackId(id)) return null;
435
+ const file = path.join(tracksDirectory(), id, "track.wav");
436
+ return (await isFile(file, { minimumBytes: 45 })) ? file : null;
437
+ },
438
+ async receipt(id) {
439
+ if (!isGeneratedTrackId(id)) return null;
440
+ try {
441
+ return JSON.parse(await readFile(path.join(tracksDirectory(), id, "receipt.json"), "utf8"));
442
+ } catch {
443
+ return null;
444
+ }
445
+ },
446
+ /**
447
+ * Makes one track and keeps it with its receipt. receipt holds what the host knows about why
448
+ * the track sounds as it does; the generator adds what it ran and what it wrote.
449
+ * Returns { id, title, seconds, seed, prompt, elapsed_ms, measured }.
450
+ */
451
+ async generate({ prompt, seconds = GENERATION_SECONDS.default, seed = randomInt(0, 2 ** 31 - 1), title, receipt = {} }, { signal, onProgress } = {}) {
452
+ if (running) throw generationError("generation_busy", "Moondog is already making a track. Wait for it to finish.");
453
+ if (!Number.isInteger(seconds) || seconds < GENERATION_SECONDS.min || seconds > GENERATION_SECONDS.max) {
454
+ throw generationError("generation_seconds_invalid", `A track can be ${GENERATION_SECONDS.min} to ${GENERATION_SECONDS.max} seconds long.`);
455
+ }
456
+ if (typeof prompt !== "string" || !prompt.trim()) throw generationError("generation_prompt_empty", "Nothing was made: the prompt is empty.");
457
+ // The slot is taken before anything waits, so two requests at once never both run.
458
+ const id = randomUUID();
459
+ running = { id };
460
+ const folder = tracksDirectory();
461
+ const temporary = path.join(folder, `.${id}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`);
462
+ const started = Date.now();
463
+ try {
464
+ const status = await check();
465
+ if (status.state !== "ready") throw generationError("generation_not_ready", "Local generation is not ready. The listener can turn it on in Settings or with /generation.");
466
+ await mkdir(folder, { recursive: true, mode: 0o700 });
467
+ await chmod(folder, 0o700);
468
+ await mkdir(temporary, { mode: 0o700 });
469
+ const wav = path.join(temporary, "track.wav");
470
+ onProgress?.({ stage: "starting" });
471
+ const measured = await run({ root: status.path, prompt, seconds, seed, out: wav }, { signal, onProgress });
472
+ const audio = await wavInfo(wav).catch(() => null);
473
+ if (!audio || audio.duration_seconds <= 0) throw generationError("generation_failed", "Stable Audio 3 finished but wrote no playable track.");
474
+ await chmod(wav, 0o600);
475
+ const elapsed = Date.now() - started;
476
+ const document = {
477
+ receipt_version: "moondog-generated-track/1",
478
+ generated_track_id: id,
479
+ moondog_id: `moondog:generated:${id}`,
480
+ created_at: now().toISOString(),
481
+ title,
482
+ ...receipt,
483
+ generator: {
484
+ adapter: "stable-audio-3", adapter_version: 1, model: "sa3-medium-ARC", decoder: "same-l",
485
+ prompt, seconds, seed, steps: 8, cfg: 1, elapsed_ms: elapsed, ...(measured ? { measured } : {}),
486
+ license: "Stability AI Community License; outputs belong to the listener",
487
+ },
488
+ audio: { file: "track.wav", ...audio },
489
+ };
490
+ await writeFile(path.join(temporary, "receipt.json"), `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
491
+ await rename(temporary, path.join(folder, id));
492
+ remember(document);
493
+ return { id, title, seconds, seed, prompt, elapsed_ms: elapsed, duration_seconds: audio.duration_seconds, ...(measured ? { measured } : {}) };
494
+ } catch (error) {
495
+ await rm(temporary, { recursive: true, force: true }).catch(() => {});
496
+ throw error;
497
+ } finally {
498
+ running = null;
499
+ }
500
+ },
501
+ };
502
+ }
503
+
504
+ // Moondog's atmosphere stream (docs/MUSIC_DECENTRALIZATION.md): from one compiled taste, a track at a
505
+ // time, each made while the last one plays and queued behind it, so a skip moves straight on. Each
506
+ // track features a different few of the taste's sounds; one the listener skips within 30 seconds
507
+ // steers the stream away from what it featured, and a track played through counts for little in the
508
+ // background. Stopping the player, other music, or the time limit ends it. One stream per room, on
509
+ // the room's one generator; only its newest tracks keep their audio, and every receipt stays.
510
+ const STREAM_SKIP_MS = 30_000;
511
+ export function createAtmosphereStream({ generator, player, now = Date.now, keepAudio = 3 }) {
512
+ let stream = null;
513
+ const end = (reason) => {
514
+ const ending = stream;
515
+ if (!ending) return null;
516
+ stream = null;
517
+ ending.controller.abort();
518
+ ending.ended = reason;
519
+ return ending;
520
+ };
521
+ const trackFor = (ending, position) => {
522
+ const variation = streamVariation(ending.compiled, position, ending.leaveOut);
523
+ return { variation, prompt: stableAudioPrompt(variation), title: `${ending.title} · ${position + 1}`,
524
+ receipt: { ...ending.receipt, shaped_by: variation.descriptors, ...(variation.bpm ? { bpm: variation.bpm } : {}),
525
+ stream: { started_at: new Date(ending.startedAt).toISOString(), position: position + 1, featured: variation.featured } } };
526
+ };
527
+ // The tracks that started playing keep their audio, the newest few; the rest keep their receipts.
528
+ const prune = (current) => {
529
+ const played = current.made.filter((made) => made.started);
530
+ for (const made of played.slice(0, Math.max(0, played.length - keepAudio))) {
531
+ if (!made.pruned) { made.pruned = true; void generator.removeAudio?.(made.id); }
532
+ }
533
+ };
534
+ // How long the track playing now was heard, pauses left out, as its outcome counts it.
535
+ const heard = (playing) => playing.heardMs + (playing.since === null ? 0 : now() - playing.since);
536
+ // Another track fits when it would end within the time limit, after what is left of the one playing now.
537
+ const fits = (current) => {
538
+ const playing = current.current;
539
+ const left = playing && !playing.finished ? Math.max(0, (playing.seconds ?? current.seconds) * 1000 - heard(playing)) : 0;
540
+ return now() + left + current.seconds * 1000 <= current.endsAt;
541
+ };
542
+ // The track playing now was left early: within 30 seconds, what it featured stays out from now on.
543
+ const leftEarly = (current) => {
544
+ const before = current.current;
545
+ if (!before || before.finished || before.steered || heard(before) >= STREAM_SKIP_MS) return;
546
+ before.steered = true;
547
+ for (const term of current.made.find((made) => made.id === before.id)?.featured ?? []) current.leaveOut.add(term);
548
+ };
549
+ // Past the time limit a queued track never starts, even after a long pause; the player stops
550
+ // before it, and the stream ends.
551
+ const expired = (current) => now() >= current.endsAt;
552
+ const endInTime = () => {
553
+ end("time");
554
+ player.stop?.();
555
+ };
556
+ // Nothing is queued after the track that just ended: the next plays once made, or the stream ends.
557
+ const ranDry = (current) => {
558
+ if (current.making || fits(current)) {
559
+ current.waiting = true;
560
+ void makeNext();
561
+ } else end("time");
562
+ };
563
+ const makeNext = async () => {
564
+ const current = stream;
565
+ if (!current || current.making || current.next || !fits(current)) return;
566
+ current.making = true;
567
+ try {
568
+ const { prompt, title, receipt, variation } = trackFor(current, current.made.length);
569
+ const made = await generator.generate({ prompt, seconds: current.seconds, title, receipt }, { signal: current.controller.signal });
570
+ if (stream !== current) return;
571
+ const file = await generator.trackFile(made.id);
572
+ if (stream !== current) return;
573
+ // A queue that ran dry waits for this track; past the time limit it does not start.
574
+ if (current.waiting && expired(current)) { end("time"); return; }
575
+ current.made.push({ id: made.id, featured: variation.featured, started: false, seconds: made.duration_seconds });
576
+ current.next = made.id;
577
+ const track = { id: made.id, title: made.title, seconds: made.duration_seconds, file };
578
+ // A queue that ran dry while this was made starts again with it.
579
+ if (current.waiting) { current.waiting = false; player.playGenerated(track); } else player.enqueueGenerated(track);
580
+ current.onTrack?.(made);
581
+ } catch (error) {
582
+ // A run stopped with the stream is not a failure; anything else ends the stream.
583
+ if (stream === current && error?.code !== "generation_stopped") end("failed");
584
+ } finally {
585
+ current.making = false;
586
+ }
587
+ };
588
+ return {
589
+ active: () => stream !== null,
590
+ status() {
591
+ if (!stream) return null;
592
+ // Fixed times, so the agent's context changes only when the stream does.
593
+ return { tracks_made: stream.made.length, started_at: new Date(stream.startedAt).toISOString(),
594
+ ends_at: new Date(stream.endsAt).toISOString(), steering_away_from: [...stream.leaveOut] };
595
+ },
596
+ /**
597
+ * Starts a stream from a compiled taste and plays its first track; the next is made at once.
598
+ * beforePlay runs once the first track exists, so other music pauses only when there is one to
599
+ * play. Returns the first track. A stream already running gives way to the new one.
600
+ */
601
+ async start({ compiled, title, seconds, minutes, receipt = {} }, { signal, onProgress, onTrack, beforePlay } = {}) {
602
+ const previous = end("replaced");
603
+ if (previous?.current) player.stop?.();
604
+ const current = { compiled, title, seconds, receipt, onTrack, startedAt: now(), endsAt: now() + minutes * 60_000,
605
+ made: [], leaveOut: new Set(), current: null, next: null, waiting: false, making: true, controller: new AbortController() };
606
+ stream = current;
607
+ const stopped = () => Object.assign(new Error("The stream was stopped before it began."), { code: "generation_stopped" });
608
+ try {
609
+ const { prompt, title: first, receipt: firstReceipt, variation } = trackFor(current, 0);
610
+ const made = await generator.generate({ prompt, seconds, title: first, receipt: firstReceipt },
611
+ { signal: signal ? AbortSignal.any([signal, current.controller.signal]) : current.controller.signal, onProgress });
612
+ const file = await generator.trackFile(made.id);
613
+ // A stream replaced, or a reply stopped, while the first track was made plays nothing.
614
+ if (stream !== current || signal?.aborted) throw stopped();
615
+ await beforePlay?.();
616
+ if (stream !== current || signal?.aborted) throw stopped();
617
+ current.made.push({ id: made.id, featured: variation.featured, started: true, seconds: made.duration_seconds });
618
+ current.current = { id: made.id, heardMs: 0, since: now(), finished: false, seconds: made.duration_seconds };
619
+ current.making = false;
620
+ // Whatever was playing stops for the first track; that stop is not the listener's.
621
+ current.starting = true;
622
+ try { player.playGenerated({ id: made.id, title: made.title, seconds: made.duration_seconds, file }); } finally { current.starting = false; }
623
+ onTrack?.(made);
624
+ void makeNext();
625
+ return { made, prompt, variation };
626
+ } catch (error) {
627
+ if (stream === current) end("failed");
628
+ throw error;
629
+ }
630
+ },
631
+ stop(reason = "asked") {
632
+ const ending = end(reason);
633
+ // Before its first track plays, the player still holds the music from before the stream, which stays.
634
+ if (ending?.current) player.stop?.();
635
+ return ending ? { tracks_made: ending.made.length } : null;
636
+ },
637
+ /** What the room's player reports, which moves the stream along or ends it. */
638
+ observe(event) {
639
+ const current = stream;
640
+ // Until its first track plays, what the player reports is about the music before it.
641
+ if (!current || !current.current) return;
642
+ const track = event?.track;
643
+ const ours = (id) => current.made.some((made) => made.id === id);
644
+ // A queue or a song that is not one of the stream's tracks is other music, which ends the stream.
645
+ if (event?.type === "queue" || event?.type === "playing") {
646
+ const items = Array.isArray(event.tracks) ? event.tracks : track ? [track] : [];
647
+ if (items.some((item) => !item?.generated || (item.id && !ours(item.id)))) { end("other_music"); return; }
648
+ }
649
+ const isCurrent = track?.id && track.id === current.current.id;
650
+ if (event?.type === "playing" && track?.id && track.id === current.next) {
651
+ // The window may already have moved on to it when the time limit had passed. The generator
652
+ // has begun counting it, and Moondog's own stop must not read as the listener's skip.
653
+ if (expired(current)) { generator.discard?.(track.id); endInTime(); return; }
654
+ leftEarly(current);
655
+ const made = current.made.find((entry) => entry.id === track.id);
656
+ current.current = { id: track.id, heardMs: 0, since: now(), finished: false, seconds: made?.seconds };
657
+ if (made) made.started = true;
658
+ current.next = null;
659
+ current.waiting = false;
660
+ prune(current);
661
+ void makeNext();
662
+ } else if (event?.type === "paused" && isCurrent && current.current.since !== null) {
663
+ current.current.heardMs += now() - current.current.since;
664
+ current.current.since = null;
665
+ } else if (event?.type === "playing" && isCurrent && current.current.since === null) {
666
+ current.current.since = now();
667
+ } else if (event?.type === "skipped" && isCurrent) {
668
+ // A skip past the end of the queue moves on to the next track once it is made.
669
+ leftEarly(current);
670
+ current.current.finished = true;
671
+ if (!current.next) ranDry(current);
672
+ else if (expired(current)) endInTime();
673
+ } else if ((event?.type === "finished" || event?.type === "failed") && isCurrent) {
674
+ // A track that could not play says nothing about the listener; it is passed over like one that ended.
675
+ current.current.finished = true;
676
+ if (!current.next) ranDry(current);
677
+ else if (expired(current)) endInTime();
678
+ } else if (event?.type === "stopped" && !current.waiting && !current.starting) {
679
+ end("stopped");
680
+ }
681
+ },
682
+ };
683
+ }