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

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.
@@ -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) {
@@ -1,7 +1,8 @@
1
1
  // Makes music on this Mac with a local Stable Audio 3 install (Stability AI's optimized MLX
2
2
  // build). The listener turns it on once, in Settings or with /generation; until then nothing
3
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.
4
+ // machine: a run never downloads anything, and the receipt keeps no absolute path. Moondog's own
5
+ // install (setup.mjs) downloads only when the listener asks, from the pinned official sources.
5
6
  import { spawn } from "node:child_process";
6
7
  import { randomBytes, randomInt, randomUUID } from "node:crypto";
7
8
  import { unwatchFile, watchFile } from "node:fs";
@@ -12,10 +13,11 @@ import path from "node:path";
12
13
  import { resolveMoondogStateDirectory } from "../../core/state-directory.mjs";
13
14
  import { streamVariation } from "../../profile/taste-compiler.mjs";
14
15
  import { resolveMoondogAuthFile } from "../../runtime/pi/persistent-credential-store.mjs";
16
+ import { DEFAULT_STABLE_AUDIO_MODEL, isStableAudioModel, SHARED_WEIGHTS, STABLE_AUDIO_MODELS } from "./sources.mjs";
15
17
 
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.
18
+ // The weights a model needs for text to music: the text encoder, the model and its decoder.
19
+ export const stableAudioWeights = (model = DEFAULT_STABLE_AUDIO_MODEL) => [...SHARED_WEIGHTS, ...STABLE_AUDIO_MODELS[model].weights];
20
+ // Medium and Small Music were trained on up to 380 seconds; very short clips come out rough.
19
21
  export const GENERATION_SECONDS = Object.freeze({ min: 15, max: 380, default: 120 });
20
22
  // A cold start reads about 4.8 GB of weights before the first step; a 380-second track then takes about 35 seconds.
21
23
  const generationTimeoutMs = 10 * 60_000;
@@ -29,6 +31,11 @@ export function defaultStableAudioRoot() {
29
31
  return path.join(homedir(), "Developer", "stable-audio-3", "optimized", "mlx");
30
32
  }
31
33
 
34
+ /** The install Moondog downloads and manages itself; the MLX build is in its mlx folder. */
35
+ export function resolveStableAudioManagedRoot(environment = process.env) {
36
+ return path.join(resolveMoondogStateDirectory(environment), "stable-audio-3");
37
+ }
38
+
32
39
  // Kept next to the playback preference, apart from runtime settings.
33
40
  export function resolveGenerationSettingsFile(environment = process.env) {
34
41
  return path.join(path.dirname(resolveMoondogAuthFile(environment)), "generation.json");
@@ -47,41 +54,92 @@ function literalFolder(value) {
47
54
  return path.isAbsolute(expanded) ? path.resolve(expanded) : null;
48
55
  }
49
56
 
57
+ export async function readSavedSettings(environment) {
58
+ try {
59
+ const document = JSON.parse(await readFile(resolveGenerationSettingsFile(environment), "utf8"));
60
+ return document?.version === 1 ? document : {};
61
+ } catch {
62
+ return {}; // Not set up yet.
63
+ }
64
+ }
65
+
50
66
  /**
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.
67
+ * The listener's choice: { enabled, path, source, model, folder }. Generation is off until they
68
+ * turn it on; MOONDOG_SA3_DIR can point at another install but never turns it on. source is
69
+ * "managed" for Moondog's own install, "settings" or "default" for the listener's own folder
70
+ * (folder, which Moondog only reads), or "environment". model picks the weights in either.
53
71
  * A missing or unreadable file reads as off, so an older or newer file never stops the room.
54
72
  */
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 : {};
73
+ export async function readGenerationSettings(environment = process.env, { platform = process.platform, arch = process.arch } = {}) {
74
+ const saved = await readSavedSettings(environment);
61
75
  const fromEnvironment = literalFolder(environment.MOONDOG_SA3_DIR);
62
76
  const fromFile = literalFolder(saved.path);
77
+ const managed = !fromEnvironment && saved.source === "managed";
78
+ const folder = fromFile ?? defaultStableAudioRoot();
79
+ let model = isStableAudioModel(saved.model) ? saved.model : DEFAULT_STABLE_AUDIO_MODEL;
80
+ // A folder Moondog doesn't manage (MOONDOG_SA3_DIR, a typed folder, ~/Developer) runs the saved model
81
+ // only if it has it, otherwise the first model it can run; a model chosen for Moondog's own install
82
+ // waits until that install is chosen again. A folder that runs nothing keeps the saved model.
83
+ if (!managed) {
84
+ for (const name of [...new Set([model, DEFAULT_STABLE_AUDIO_MODEL, ...Object.keys(STABLE_AUDIO_MODELS)])]) {
85
+ if ((await checkStableAudioInstall(fromEnvironment ?? folder, { model: name, platform, arch })).state === "ready") { model = name; break; }
86
+ }
87
+ }
63
88
  return {
64
89
  enabled: saved.enabled === true,
65
- path: fromEnvironment ?? fromFile ?? defaultStableAudioRoot(),
66
- source: fromEnvironment ? "environment" : fromFile ? "settings" : "default",
90
+ path: fromEnvironment ?? (managed ? path.join(resolveStableAudioManagedRoot(environment), "mlx") : folder),
91
+ source: fromEnvironment ? "environment" : managed ? "managed" : fromFile ? "settings" : "default",
92
+ model,
93
+ folder,
67
94
  };
68
95
  }
69
96
 
70
- export async function writeGenerationSettings({ enabled, path: folder } = {}, environment = process.env) {
71
- const current = await readGenerationSettings(environment);
97
+ /**
98
+ * Saves what the listener changed: enabled; path, their own folder, which also switches to it;
99
+ * source "managed" or "settings" to switch between Moondog's install and their folder; model.
100
+ * A model chosen without a folder or a source switches to Moondog's install, which holds it.
101
+ * Older Moondogs read the same version 1 file and see only enabled and path.
102
+ */
103
+ export async function writeGenerationSettings({ enabled, path: folder, source, model } = {}, environment = process.env, { platform = process.platform, arch = process.arch } = {}) {
104
+ // The file's own view: a folder MOONDOG_SA3_DIR pins in this process is never written into it.
105
+ const current = await readGenerationSettings({ ...environment, MOONDOG_SA3_DIR: "" }, { platform, arch });
106
+ const saved = await readSavedSettings(environment);
72
107
  const typed = folder === undefined ? undefined : literalFolder(folder);
73
108
  if (folder !== undefined && folder !== null && !typed) {
74
109
  throw Object.assign(new Error("Give the Stable Audio 3 folder as a full path, or one starting with ~/."), { code: "generation_path_invalid" });
75
110
  }
111
+ if (model !== undefined && !isStableAudioModel(model)) {
112
+ throw Object.assign(new Error("Choose medium or small-music."), { code: "generation_model_invalid" });
113
+ }
114
+ const savedFolder = typed ?? literalFolder(saved.path);
115
+ const managed = typed ? false
116
+ : source === "managed" ? true
117
+ : source === "settings" ? false
118
+ : model !== undefined ? true
119
+ : saved.source === "managed";
120
+ // Switching to the listener's own folder without a model keeps the model only if that folder has it;
121
+ // otherwise the first model it can run, so a working install is never checked for weights it lacks.
122
+ let chosen = model ?? current.model;
123
+ if (model === undefined && !managed && (typed || source === "settings")) {
124
+ const folderRoot = savedFolder ?? current.folder;
125
+ const candidates = [current.model, DEFAULT_STABLE_AUDIO_MODEL, ...Object.keys(STABLE_AUDIO_MODELS)];
126
+ // A folder that runs nothing yet gets Medium, the model the install steps Moondog shows download.
127
+ chosen = DEFAULT_STABLE_AUDIO_MODEL;
128
+ for (const name of [...new Set(candidates)]) {
129
+ if ((await checkStableAudioInstall(folderRoot, { model: name, platform, arch })).state === "ready") { chosen = name; break; }
130
+ }
131
+ }
76
132
  const document = {
77
133
  version: 1,
78
134
  enabled: enabled === undefined ? current.enabled : enabled === true,
79
- ...(typed ? { path: typed } : current.source === "settings" ? { path: current.path } : {}),
135
+ ...(savedFolder ? { path: savedFolder } : {}),
136
+ ...(managed ? { source: "managed" } : {}),
137
+ model: chosen,
80
138
  };
81
139
  const file = resolveGenerationSettingsFile(environment);
82
140
  await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
83
141
  await writeFile(file, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
84
- return readGenerationSettings(environment);
142
+ return readGenerationSettings(environment, { platform, arch });
85
143
  }
86
144
 
87
145
  const isFile = async (file, { executable = false, minimumBytes = 0 } = {}) => {
@@ -97,16 +155,16 @@ const isFile = async (file, { executable = false, minimumBytes = 0 } = {}) => {
97
155
  };
98
156
 
99
157
  /**
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).
158
+ * Whether an install can make music now with a model, without running it: "ready", "unsupported"
159
+ * (MLX needs Apple silicon), "missing" (no install at the folder) or "incomplete" (no Python
160
+ * environment or not that model's weights, which Moondog's setup or Stability's ./install.sh fetches).
103
161
  */
104
- export async function checkStableAudioInstall(root, { platform = process.platform, arch = process.arch } = {}) {
162
+ export async function checkStableAudioInstall(root, { model = DEFAULT_STABLE_AUDIO_MODEL, platform = process.platform, arch = process.arch } = {}) {
105
163
  if (platform !== "darwin" || arch !== "arm64") return { state: "unsupported" };
106
164
  if (!root || !path.isAbsolute(root)) return { state: "missing" };
107
165
  if (!(await isFile(path.join(root, "scripts", "sa3_mlx.py")))) return { state: "missing" };
108
166
  if (!(await isFile(path.join(root, ".venv", "bin", "python"), { executable: true }))) return { state: "incomplete", missing: "environment" };
109
- for (const weight of MEDIUM_WEIGHTS) {
167
+ for (const weight of stableAudioWeights(isStableAudioModel(model) ? model : DEFAULT_STABLE_AUDIO_MODEL)) {
110
168
  if (!(await isFile(path.join(root, "models", "mlx", weight), { minimumBytes: 1024 }))) return { state: "incomplete", missing: "weights" };
111
169
  }
112
170
  return { state: "ready" };
@@ -171,7 +229,7 @@ export function createLocalGenerator({
171
229
  spawnImpl = spawn,
172
230
  now = () => new Date(),
173
231
  } = {}) {
174
- let current = { state: "unknown", enabled: false, install: "unknown", path: null, source: null };
232
+ let current = { state: "unknown", enabled: false, install: "unknown", path: null, source: null, model: DEFAULT_STABLE_AUDIO_MODEL, managed: false };
175
233
  let running = null;
176
234
  // The tracks made here, newest last, with what happened each time they played: the loop back
177
235
  // from listening reads them. Each track's outcomes.jsonl keeps them; the receipt never changes.
@@ -196,8 +254,8 @@ export function createLocalGenerator({
196
254
  const check = () => {
197
255
  checking ??= (async () => {
198
256
  try {
199
- const settings = await readGenerationSettings(environment);
200
- const install = await checkStableAudioInstall(settings.path, { platform, arch });
257
+ const settings = await readGenerationSettings(environment, { platform, arch });
258
+ const install = await checkStableAudioInstall(settings.path, { model: settings.model, platform, arch });
201
259
  return publish({
202
260
  state: !settings.enabled ? "off" : install.state,
203
261
  enabled: settings.enabled,
@@ -205,6 +263,8 @@ export function createLocalGenerator({
205
263
  ...(install.missing ? { missing: install.missing } : {}),
206
264
  path: settings.path,
207
265
  source: settings.source,
266
+ model: settings.model,
267
+ managed: settings.source === "managed",
208
268
  });
209
269
  } finally {
210
270
  checking = null;
@@ -233,13 +293,13 @@ export function createLocalGenerator({
233
293
  };
234
294
 
235
295
  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) => {
296
+ const run = ({ root, model, prompt, seconds, seed, out }, { signal, onProgress }) => new Promise((resolve, reject) => {
237
297
  // A stop that came while the install was checked starts nothing.
238
298
  if (signal?.aborted) return reject(stopped());
239
299
  // The Python script itself, not Stability's ./sa3 wrapper: the wrapper wants uv on PATH and a
240
300
  // terminal, and its exec would leave a shell between this process and the one to stop.
241
301
  const child = spawnImpl(path.join(root, ".venv", "bin", "python"), [
242
- "scripts/sa3_mlx.py", "--prompt", prompt, "--dit", "medium", "--decoder", "same-l",
302
+ "scripts/sa3_mlx.py", "--prompt", prompt, "--dit", STABLE_AUDIO_MODELS[model].dit, "--decoder", STABLE_AUDIO_MODELS[model].decoder,
243
303
  "--seconds", String(seconds), "--seed", String(seed), "--steps", "8", "--out", out,
244
304
  ], {
245
305
  cwd: root,
@@ -414,7 +474,7 @@ export function createLocalGenerator({
414
474
  busy: () => running !== null,
415
475
  check,
416
476
  async save(settings) {
417
- await writeGenerationSettings(settings, environment);
477
+ await writeGenerationSettings(settings, environment, { platform, arch });
418
478
  return check();
419
479
  },
420
480
  subscribe(listener) {
@@ -469,7 +529,8 @@ export function createLocalGenerator({
469
529
  await mkdir(temporary, { mode: 0o700 });
470
530
  const wav = path.join(temporary, "track.wav");
471
531
  onProgress?.({ stage: "starting" });
472
- const measured = await run({ root: status.path, prompt, seconds, seed, out: wav }, { signal, onProgress });
532
+ const model = isStableAudioModel(status.model) ? status.model : DEFAULT_STABLE_AUDIO_MODEL;
533
+ const measured = await run({ root: status.path, model, prompt, seconds, seed, out: wav }, { signal, onProgress });
473
534
  const audio = await wavInfo(wav).catch(() => null);
474
535
  if (!audio || audio.duration_seconds <= 0) throw generationError("generation_failed", "Stable Audio 3 finished but wrote no playable track.");
475
536
  await chmod(wav, 0o600);
@@ -482,7 +543,7 @@ export function createLocalGenerator({
482
543
  title,
483
544
  ...receipt,
484
545
  generator: {
485
- adapter: "stable-audio-3", adapter_version: 1, model: "sa3-medium-ARC", decoder: "same-l",
546
+ adapter: "stable-audio-3", adapter_version: 1, model: STABLE_AUDIO_MODELS[model].receipt, decoder: STABLE_AUDIO_MODELS[model].decoder,
486
547
  prompt, seconds, seed, steps: 8, cfg: 1, elapsed_ms: elapsed, ...(measured ? { measured } : {}),
487
548
  license: "Stability AI Community License; outputs belong to the listener",
488
549
  },