@audiofool/moondog 0.3.0-beta.1 → 0.3.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +1 -1
- package/scripts/moondog.mjs +138 -10
- package/src/core/capability-catalog.mjs +49 -0
- package/src/core/library-store.mjs +500 -0
- package/src/core/listening-now.mjs +80 -6
- package/src/core/moondog-application.mjs +570 -32
- package/src/i18n/index.mjs +6 -3
- package/src/i18n/messages/de.mjs +1 -0
- package/src/i18n/messages/en.mjs +2 -0
- package/src/i18n/messages/es.mjs +1 -0
- package/src/i18n/messages/fr.mjs +1 -0
- package/src/i18n/messages/it.mjs +1 -0
- package/src/i18n/messages/ja.mjs +1 -0
- package/src/i18n/messages/ko.mjs +1 -0
- package/src/i18n/messages/pt.mjs +1 -0
- package/src/i18n/messages/zh-Hant.mjs +1 -0
- package/src/i18n/messages/zh.mjs +1 -0
- package/src/i18n/preferences.mjs +24 -0
- package/src/i18n/screen/es.mjs +92 -0
- package/src/i18n/screen/ja.mjs +92 -0
- package/src/i18n/screen/pt.mjs +92 -0
- package/src/i18n/screen/zh.mjs +92 -0
- package/src/integrations/apple-music/music-app.mjs +9 -3
- package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
- package/src/integrations/netease/ncm-cli-player.mjs +12 -6
- package/src/integrations/playlist-links.mjs +96 -0
- package/src/integrations/public-playlists.mjs +10 -4
- package/src/integrations/spotify/service.mjs +22 -1
- package/src/integrations/stable-audio/cover.mjs +324 -0
- package/src/integrations/stable-audio/local-generator.mjs +259 -87
- package/src/integrations/stable-audio/setup.mjs +653 -0
- package/src/integrations/stable-audio/sources.mjs +106 -0
- package/src/profile/listening-history-store.mjs +1 -1
- package/src/profile/listening-profile-projection.mjs +1 -1
- package/src/profile/music-import-bundle.mjs +3 -1
- package/src/profile/music-providers.mjs +3 -0
- package/src/profile/taste-compiler.mjs +14 -5
- package/src/runtime/pi/agent-runtime.mjs +324 -31
- package/src/runtime/pi/model-switch.mjs +26 -0
- package/src/runtime/pi/model-transport.mjs +34 -4
- package/src/runtime/pi/record-name.mjs +80 -0
- package/src/runtime/pi/reply-language.mjs +45 -0
- package/src/runtime/pi/tool-arguments.mjs +89 -0
- package/src/surfaces/cli/command-completions.mjs +15 -1
- package/src/surfaces/cli/tui.mjs +404 -18
|
@@ -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:
|
|
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,12 @@ 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 { readLoudness } from "./cover.mjs";
|
|
17
|
+
import { DEFAULT_STABLE_AUDIO_MODEL, isStableAudioModel, SHARED_WEIGHTS, STABLE_AUDIO_MODELS } from "./sources.mjs";
|
|
15
18
|
|
|
16
|
-
// The weights
|
|
17
|
-
const
|
|
18
|
-
// Medium
|
|
19
|
+
// The weights a model needs for text to music: the text encoder, the model and its decoder.
|
|
20
|
+
export const stableAudioWeights = (model = DEFAULT_STABLE_AUDIO_MODEL) => [...SHARED_WEIGHTS, ...STABLE_AUDIO_MODELS[model].weights];
|
|
21
|
+
// Medium and Small Music were trained on up to 380 seconds; very short clips come out rough.
|
|
19
22
|
export const GENERATION_SECONDS = Object.freeze({ min: 15, max: 380, default: 120 });
|
|
20
23
|
// A cold start reads about 4.8 GB of weights before the first step; a 380-second track then takes about 35 seconds.
|
|
21
24
|
const generationTimeoutMs = 10 * 60_000;
|
|
@@ -29,6 +32,11 @@ export function defaultStableAudioRoot() {
|
|
|
29
32
|
return path.join(homedir(), "Developer", "stable-audio-3", "optimized", "mlx");
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
/** The install Moondog downloads and manages itself; the MLX build is in its mlx folder. */
|
|
36
|
+
export function resolveStableAudioManagedRoot(environment = process.env) {
|
|
37
|
+
return path.join(resolveMoondogStateDirectory(environment), "stable-audio-3");
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
// Kept next to the playback preference, apart from runtime settings.
|
|
33
41
|
export function resolveGenerationSettingsFile(environment = process.env) {
|
|
34
42
|
return path.join(path.dirname(resolveMoondogAuthFile(environment)), "generation.json");
|
|
@@ -47,41 +55,92 @@ function literalFolder(value) {
|
|
|
47
55
|
return path.isAbsolute(expanded) ? path.resolve(expanded) : null;
|
|
48
56
|
}
|
|
49
57
|
|
|
58
|
+
export async function readSavedSettings(environment) {
|
|
59
|
+
try {
|
|
60
|
+
const document = JSON.parse(await readFile(resolveGenerationSettingsFile(environment), "utf8"));
|
|
61
|
+
return document?.version === 1 ? document : {};
|
|
62
|
+
} catch {
|
|
63
|
+
return {}; // Not set up yet.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
/**
|
|
51
|
-
* The listener's choice: { enabled, path, source }. Generation is off until they
|
|
52
|
-
* MOONDOG_SA3_DIR can point at another install but never turns it on.
|
|
68
|
+
* The listener's choice: { enabled, path, source, model, folder }. Generation is off until they
|
|
69
|
+
* turn it on; MOONDOG_SA3_DIR can point at another install but never turns it on. source is
|
|
70
|
+
* "managed" for Moondog's own install, "settings" or "default" for the listener's own folder
|
|
71
|
+
* (folder, which Moondog only reads), or "environment". model picks the weights in either.
|
|
53
72
|
* A missing or unreadable file reads as off, so an older or newer file never stops the room.
|
|
54
73
|
*/
|
|
55
|
-
export async function readGenerationSettings(environment = process.env) {
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
document = JSON.parse(await readFile(resolveGenerationSettingsFile(environment), "utf8"));
|
|
59
|
-
} catch { /* Not set up yet. */ }
|
|
60
|
-
const saved = document?.version === 1 ? document : {};
|
|
74
|
+
export async function readGenerationSettings(environment = process.env, { platform = process.platform, arch = process.arch } = {}) {
|
|
75
|
+
const saved = await readSavedSettings(environment);
|
|
61
76
|
const fromEnvironment = literalFolder(environment.MOONDOG_SA3_DIR);
|
|
62
77
|
const fromFile = literalFolder(saved.path);
|
|
78
|
+
const managed = !fromEnvironment && saved.source === "managed";
|
|
79
|
+
const folder = fromFile ?? defaultStableAudioRoot();
|
|
80
|
+
let model = isStableAudioModel(saved.model) ? saved.model : DEFAULT_STABLE_AUDIO_MODEL;
|
|
81
|
+
// A folder Moondog doesn't manage (MOONDOG_SA3_DIR, a typed folder, ~/Developer) runs the saved model
|
|
82
|
+
// only if it has it, otherwise the first model it can run; a model chosen for Moondog's own install
|
|
83
|
+
// waits until that install is chosen again. A folder that runs nothing keeps the saved model.
|
|
84
|
+
if (!managed) {
|
|
85
|
+
for (const name of [...new Set([model, DEFAULT_STABLE_AUDIO_MODEL, ...Object.keys(STABLE_AUDIO_MODELS)])]) {
|
|
86
|
+
if ((await checkStableAudioInstall(fromEnvironment ?? folder, { model: name, platform, arch })).state === "ready") { model = name; break; }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
63
89
|
return {
|
|
64
90
|
enabled: saved.enabled === true,
|
|
65
|
-
path: fromEnvironment ??
|
|
66
|
-
source: fromEnvironment ? "environment" : fromFile ? "settings" : "default",
|
|
91
|
+
path: fromEnvironment ?? (managed ? path.join(resolveStableAudioManagedRoot(environment), "mlx") : folder),
|
|
92
|
+
source: fromEnvironment ? "environment" : managed ? "managed" : fromFile ? "settings" : "default",
|
|
93
|
+
model,
|
|
94
|
+
folder,
|
|
67
95
|
};
|
|
68
96
|
}
|
|
69
97
|
|
|
70
|
-
|
|
71
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Saves what the listener changed: enabled; path, their own folder, which also switches to it;
|
|
100
|
+
* source "managed" or "settings" to switch between Moondog's install and their folder; model.
|
|
101
|
+
* A model chosen without a folder or a source switches to Moondog's install, which holds it.
|
|
102
|
+
* Older Moondogs read the same version 1 file and see only enabled and path.
|
|
103
|
+
*/
|
|
104
|
+
export async function writeGenerationSettings({ enabled, path: folder, source, model } = {}, environment = process.env, { platform = process.platform, arch = process.arch } = {}) {
|
|
105
|
+
// The file's own view: a folder MOONDOG_SA3_DIR pins in this process is never written into it.
|
|
106
|
+
const current = await readGenerationSettings({ ...environment, MOONDOG_SA3_DIR: "" }, { platform, arch });
|
|
107
|
+
const saved = await readSavedSettings(environment);
|
|
72
108
|
const typed = folder === undefined ? undefined : literalFolder(folder);
|
|
73
109
|
if (folder !== undefined && folder !== null && !typed) {
|
|
74
110
|
throw Object.assign(new Error("Give the Stable Audio 3 folder as a full path, or one starting with ~/."), { code: "generation_path_invalid" });
|
|
75
111
|
}
|
|
112
|
+
if (model !== undefined && !isStableAudioModel(model)) {
|
|
113
|
+
throw Object.assign(new Error("Choose medium or small-music."), { code: "generation_model_invalid" });
|
|
114
|
+
}
|
|
115
|
+
const savedFolder = typed ?? literalFolder(saved.path);
|
|
116
|
+
const managed = typed ? false
|
|
117
|
+
: source === "managed" ? true
|
|
118
|
+
: source === "settings" ? false
|
|
119
|
+
: model !== undefined ? true
|
|
120
|
+
: saved.source === "managed";
|
|
121
|
+
// Switching to the listener's own folder without a model keeps the model only if that folder has it;
|
|
122
|
+
// otherwise the first model it can run, so a working install is never checked for weights it lacks.
|
|
123
|
+
let chosen = model ?? current.model;
|
|
124
|
+
if (model === undefined && !managed && (typed || source === "settings")) {
|
|
125
|
+
const folderRoot = savedFolder ?? current.folder;
|
|
126
|
+
const candidates = [current.model, DEFAULT_STABLE_AUDIO_MODEL, ...Object.keys(STABLE_AUDIO_MODELS)];
|
|
127
|
+
// A folder that runs nothing yet gets Medium, the model the install steps Moondog shows download.
|
|
128
|
+
chosen = DEFAULT_STABLE_AUDIO_MODEL;
|
|
129
|
+
for (const name of [...new Set(candidates)]) {
|
|
130
|
+
if ((await checkStableAudioInstall(folderRoot, { model: name, platform, arch })).state === "ready") { chosen = name; break; }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
76
133
|
const document = {
|
|
77
134
|
version: 1,
|
|
78
135
|
enabled: enabled === undefined ? current.enabled : enabled === true,
|
|
79
|
-
...(
|
|
136
|
+
...(savedFolder ? { path: savedFolder } : {}),
|
|
137
|
+
...(managed ? { source: "managed" } : {}),
|
|
138
|
+
model: chosen,
|
|
80
139
|
};
|
|
81
140
|
const file = resolveGenerationSettingsFile(environment);
|
|
82
141
|
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
83
142
|
await writeFile(file, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
84
|
-
return readGenerationSettings(environment);
|
|
143
|
+
return readGenerationSettings(environment, { platform, arch });
|
|
85
144
|
}
|
|
86
145
|
|
|
87
146
|
const isFile = async (file, { executable = false, minimumBytes = 0 } = {}) => {
|
|
@@ -97,16 +156,16 @@ const isFile = async (file, { executable = false, minimumBytes = 0 } = {}) => {
|
|
|
97
156
|
};
|
|
98
157
|
|
|
99
158
|
/**
|
|
100
|
-
* Whether an install can make music now, without running it: "ready", "unsupported"
|
|
101
|
-
* Apple silicon), "missing" (no install at the folder) or "incomplete" (no Python
|
|
102
|
-
* or
|
|
159
|
+
* Whether an install can make music now with a model, without running it: "ready", "unsupported"
|
|
160
|
+
* (MLX needs Apple silicon), "missing" (no install at the folder) or "incomplete" (no Python
|
|
161
|
+
* environment or not that model's weights, which Moondog's setup or Stability's ./install.sh fetches).
|
|
103
162
|
*/
|
|
104
|
-
export async function checkStableAudioInstall(root, { platform = process.platform, arch = process.arch } = {}) {
|
|
163
|
+
export async function checkStableAudioInstall(root, { model = DEFAULT_STABLE_AUDIO_MODEL, platform = process.platform, arch = process.arch } = {}) {
|
|
105
164
|
if (platform !== "darwin" || arch !== "arm64") return { state: "unsupported" };
|
|
106
165
|
if (!root || !path.isAbsolute(root)) return { state: "missing" };
|
|
107
166
|
if (!(await isFile(path.join(root, "scripts", "sa3_mlx.py")))) return { state: "missing" };
|
|
108
167
|
if (!(await isFile(path.join(root, ".venv", "bin", "python"), { executable: true }))) return { state: "incomplete", missing: "environment" };
|
|
109
|
-
for (const weight of
|
|
168
|
+
for (const weight of stableAudioWeights(isStableAudioModel(model) ? model : DEFAULT_STABLE_AUDIO_MODEL)) {
|
|
110
169
|
if (!(await isFile(path.join(root, "models", "mlx", weight), { minimumBytes: 1024 }))) return { state: "incomplete", missing: "weights" };
|
|
111
170
|
}
|
|
112
171
|
return { state: "ready" };
|
|
@@ -170,8 +229,10 @@ export function createLocalGenerator({
|
|
|
170
229
|
arch = process.arch,
|
|
171
230
|
spawnImpl = spawn,
|
|
172
231
|
now = () => new Date(),
|
|
232
|
+
// Whether the listener's library holds a made track: a stream never drops the audio of one they kept.
|
|
233
|
+
keeps = () => false,
|
|
173
234
|
} = {}) {
|
|
174
|
-
let current = { state: "unknown", enabled: false, install: "unknown", path: null, source: null };
|
|
235
|
+
let current = { state: "unknown", enabled: false, install: "unknown", path: null, source: null, model: DEFAULT_STABLE_AUDIO_MODEL, managed: false };
|
|
175
236
|
let running = null;
|
|
176
237
|
// The tracks made here, newest last, with what happened each time they played: the loop back
|
|
177
238
|
// from listening reads them. Each track's outcomes.jsonl keeps them; the receipt never changes.
|
|
@@ -196,8 +257,8 @@ export function createLocalGenerator({
|
|
|
196
257
|
const check = () => {
|
|
197
258
|
checking ??= (async () => {
|
|
198
259
|
try {
|
|
199
|
-
const settings = await readGenerationSettings(environment);
|
|
200
|
-
const install = await checkStableAudioInstall(settings.path, { platform, arch });
|
|
260
|
+
const settings = await readGenerationSettings(environment, { platform, arch });
|
|
261
|
+
const install = await checkStableAudioInstall(settings.path, { model: settings.model, platform, arch });
|
|
201
262
|
return publish({
|
|
202
263
|
state: !settings.enabled ? "off" : install.state,
|
|
203
264
|
enabled: settings.enabled,
|
|
@@ -205,6 +266,8 @@ export function createLocalGenerator({
|
|
|
205
266
|
...(install.missing ? { missing: install.missing } : {}),
|
|
206
267
|
path: settings.path,
|
|
207
268
|
source: settings.source,
|
|
269
|
+
model: settings.model,
|
|
270
|
+
managed: settings.source === "managed",
|
|
208
271
|
});
|
|
209
272
|
} finally {
|
|
210
273
|
checking = null;
|
|
@@ -233,13 +296,13 @@ export function createLocalGenerator({
|
|
|
233
296
|
};
|
|
234
297
|
|
|
235
298
|
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) => {
|
|
299
|
+
const run = ({ root, model, prompt, seconds, seed, out }, { signal, onProgress }) => new Promise((resolve, reject) => {
|
|
237
300
|
// A stop that came while the install was checked starts nothing.
|
|
238
301
|
if (signal?.aborted) return reject(stopped());
|
|
239
302
|
// The Python script itself, not Stability's ./sa3 wrapper: the wrapper wants uv on PATH and a
|
|
240
303
|
// terminal, and its exec would leave a shell between this process and the one to stop.
|
|
241
304
|
const child = spawnImpl(path.join(root, ".venv", "bin", "python"), [
|
|
242
|
-
"scripts/sa3_mlx.py", "--prompt", prompt, "--dit",
|
|
305
|
+
"scripts/sa3_mlx.py", "--prompt", prompt, "--dit", STABLE_AUDIO_MODELS[model].dit, "--decoder", STABLE_AUDIO_MODELS[model].decoder,
|
|
243
306
|
"--seconds", String(seconds), "--seed", String(seed), "--steps", "8", "--out", out,
|
|
244
307
|
], {
|
|
245
308
|
cwd: root,
|
|
@@ -376,6 +439,9 @@ export function createLocalGenerator({
|
|
|
376
439
|
close(false);
|
|
377
440
|
playing = { id: track.id, heardMs: 0, since: Date.now() };
|
|
378
441
|
} else if (playing.since === null) playing.since = Date.now();
|
|
442
|
+
} else if (event?.type === "playing" && playing && track && track.generated !== true) {
|
|
443
|
+
// A song from the catalog after a made track in a library playlist: the made track's play ended there.
|
|
444
|
+
close(false);
|
|
379
445
|
} else if (event?.type === "paused" && playing && playing.since !== null) {
|
|
380
446
|
playing.heardMs += Date.now() - playing.since;
|
|
381
447
|
playing.since = null;
|
|
@@ -391,9 +457,11 @@ export function createLocalGenerator({
|
|
|
391
457
|
/** Waits for the outcomes still being written, as a room does before it closes. */
|
|
392
458
|
flush: () => Promise.allSettled([...writing]),
|
|
393
459
|
// 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.
|
|
460
|
+
// A stream keeps only its newest tracks' audio; the receipt and outcomes of the rest stay. A track in the
|
|
461
|
+
// listener's library keeps its audio, and a library that can't be read keeps it too.
|
|
395
462
|
async removeAudio(id) {
|
|
396
463
|
if (!isGeneratedTrackId(id)) return;
|
|
464
|
+
try { if (keeps(id)) return; } catch { return; }
|
|
397
465
|
await rm(path.join(tracksDirectory(), id, "track.wav"), { force: true }).catch(() => {});
|
|
398
466
|
const entry = made.get(id);
|
|
399
467
|
if (entry) entry.audio_kept = false;
|
|
@@ -401,9 +469,10 @@ export function createLocalGenerator({
|
|
|
401
469
|
restart(id) {
|
|
402
470
|
if (playing?.id === id) playing = null;
|
|
403
471
|
},
|
|
404
|
-
// A play Moondog cut off as it began, as a stream past its time limit does, says nothing about the
|
|
472
|
+
// A play Moondog cut off as it began, as a stream past its time limit does, says nothing about the
|
|
473
|
+
// listener. Without an id, whatever made track plays now, as when a stream's first track replaces it.
|
|
405
474
|
discard(id) {
|
|
406
|
-
if (playing
|
|
475
|
+
if (playing && (id === undefined || playing.id === id)) playing = null;
|
|
407
476
|
},
|
|
408
477
|
recordReplay(id, by) {
|
|
409
478
|
this.restart(id);
|
|
@@ -413,7 +482,7 @@ export function createLocalGenerator({
|
|
|
413
482
|
busy: () => running !== null,
|
|
414
483
|
check,
|
|
415
484
|
async save(settings) {
|
|
416
|
-
await writeGenerationSettings(settings, environment);
|
|
485
|
+
await writeGenerationSettings(settings, environment, { platform, arch });
|
|
417
486
|
return check();
|
|
418
487
|
},
|
|
419
488
|
subscribe(listener) {
|
|
@@ -468,10 +537,13 @@ export function createLocalGenerator({
|
|
|
468
537
|
await mkdir(temporary, { mode: 0o700 });
|
|
469
538
|
const wav = path.join(temporary, "track.wav");
|
|
470
539
|
onProgress?.({ stage: "starting" });
|
|
471
|
-
const
|
|
540
|
+
const model = isStableAudioModel(status.model) ? status.model : DEFAULT_STABLE_AUDIO_MODEL;
|
|
541
|
+
const measured = await run({ root: status.path, model, prompt, seconds, seed, out: wav }, { signal, onProgress });
|
|
472
542
|
const audio = await wavInfo(wav).catch(() => null);
|
|
473
543
|
if (!audio || audio.duration_seconds <= 0) throw generationError("generation_failed", "Stable Audio 3 finished but wrote no playable track.");
|
|
474
544
|
await chmod(wav, 0o600);
|
|
545
|
+
// How loud it runs from start to end, which its cover draws (cover.mjs); kept after a stream drops the audio.
|
|
546
|
+
const loudness = await readLoudness(wav).catch(() => null);
|
|
475
547
|
const elapsed = Date.now() - started;
|
|
476
548
|
const document = {
|
|
477
549
|
receipt_version: "moondog-generated-track/1",
|
|
@@ -481,11 +553,11 @@ export function createLocalGenerator({
|
|
|
481
553
|
title,
|
|
482
554
|
...receipt,
|
|
483
555
|
generator: {
|
|
484
|
-
adapter: "stable-audio-3", adapter_version: 1, model:
|
|
556
|
+
adapter: "stable-audio-3", adapter_version: 1, model: STABLE_AUDIO_MODELS[model].receipt, decoder: STABLE_AUDIO_MODELS[model].decoder,
|
|
485
557
|
prompt, seconds, seed, steps: 8, cfg: 1, elapsed_ms: elapsed, ...(measured ? { measured } : {}),
|
|
486
558
|
license: "Stability AI Community License; outputs belong to the listener",
|
|
487
559
|
},
|
|
488
|
-
audio: { file: "track.wav", ...audio },
|
|
560
|
+
audio: { file: "track.wav", ...audio, ...(loudness ? { loudness } : {}) },
|
|
489
561
|
};
|
|
490
562
|
await writeFile(path.join(temporary, "receipt.json"), `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
491
563
|
await rename(temporary, path.join(folder, id));
|
|
@@ -508,21 +580,85 @@ export function createLocalGenerator({
|
|
|
508
580
|
// background. Stopping the player, other music, or the time limit ends it. One stream per room, on
|
|
509
581
|
// the room's one generator; only its newest tracks keep their audio, and every receipt stays.
|
|
510
582
|
const STREAM_SKIP_MS = 30_000;
|
|
583
|
+
// How long a later track waits for its own name before it takes a numbered one.
|
|
584
|
+
const NAMING_TIMEOUT_MS = 10_000;
|
|
511
585
|
export function createAtmosphereStream({ generator, player, now = Date.now, keepAudio = 3 }) {
|
|
512
586
|
let stream = null;
|
|
587
|
+
// What the surfaces see (view()) and hear (subscribe): the phase, and the end with its reason.
|
|
588
|
+
const listeners = new Set();
|
|
589
|
+
let shown = "null";
|
|
590
|
+
// Events wait their turn, so a listener that stops the stream from inside one never reorders them.
|
|
591
|
+
const events = [];
|
|
592
|
+
let delivering = false;
|
|
593
|
+
const deliver = (event) => {
|
|
594
|
+
events.push(event);
|
|
595
|
+
if (delivering) return;
|
|
596
|
+
delivering = true;
|
|
597
|
+
try {
|
|
598
|
+
while (events.length) {
|
|
599
|
+
const next = events.shift();
|
|
600
|
+
for (const listener of [...listeners]) {
|
|
601
|
+
try { listener(structuredClone(next)); } catch { /* A surface's redraw cannot stop the stream. */ }
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
} finally {
|
|
605
|
+
delivering = false;
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
const viewOf = (current) => current ? {
|
|
609
|
+
// Nothing of it plays yet; it waits in the dry gap for its next track; it makes the next behind one loaded; or it only plays.
|
|
610
|
+
phase: !current.current ? "starting" : current.waiting ? "waiting" : current.making ? "making" : "playing",
|
|
611
|
+
started_at: new Date(current.startedAt).toISOString(), ends_at: new Date(current.endsAt).toISOString(), tracks_made: current.made.length,
|
|
612
|
+
} : null;
|
|
613
|
+
// Called after anything that may change the view; only a real change reaches the listeners.
|
|
614
|
+
const changed = () => {
|
|
615
|
+
const view = viewOf(stream);
|
|
616
|
+
const key = JSON.stringify(view);
|
|
617
|
+
if (key === shown) return;
|
|
618
|
+
shown = key;
|
|
619
|
+
deliver({ view, ended: null });
|
|
620
|
+
};
|
|
621
|
+
// The tracks of a stream that gave way to Moondog's next one, while the player may still hold one of
|
|
622
|
+
// them; a listener's stop before the new stream's first track plays stops them too.
|
|
623
|
+
let heldOver = null;
|
|
513
624
|
const end = (reason) => {
|
|
514
625
|
const ending = stream;
|
|
515
626
|
if (!ending) return null;
|
|
516
627
|
stream = null;
|
|
517
628
|
ending.controller.abort();
|
|
518
629
|
ending.ended = reason;
|
|
630
|
+
heldOver = reason === "replaced" && (ending.current || heldOver)
|
|
631
|
+
? new Set([...(heldOver ?? []), ...ending.made.map((made) => made.id)]) : null;
|
|
632
|
+
shown = "null";
|
|
633
|
+
deliver({ view: null, ended: reason });
|
|
519
634
|
return ending;
|
|
520
635
|
};
|
|
521
636
|
const trackFor = (ending, position) => {
|
|
522
637
|
const variation = streamVariation(ending.compiled, position, ending.leaveOut);
|
|
523
|
-
|
|
638
|
+
// The first track carries the stream's own name; a later one is named as it is made (nameFor), or numbered.
|
|
639
|
+
return { variation, prompt: stableAudioPrompt(variation), title: position === 0 ? ending.title : `${ending.title} · ${position + 1}`,
|
|
524
640
|
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 } } };
|
|
641
|
+
stream: { name: ending.title, started_at: new Date(ending.startedAt).toISOString(), position: position + 1, featured: variation.featured } } };
|
|
642
|
+
};
|
|
643
|
+
// A name of its own for a later track, asked of the stream's namer within a few seconds. One that
|
|
644
|
+
// fails, is slow, or repeats a name this stream gave keeps the numbered name.
|
|
645
|
+
const nameFor = async (ending, variation, numbered) => {
|
|
646
|
+
if (typeof ending.nameTrack !== "function") return numbered;
|
|
647
|
+
const timeout = AbortSignal.timeout(NAMING_TIMEOUT_MS);
|
|
648
|
+
try {
|
|
649
|
+
const name = await Promise.race([
|
|
650
|
+
ending.nameTrack({
|
|
651
|
+
stream: ending.title, featured: variation.featured,
|
|
652
|
+
sound: variation.descriptors.map((descriptor) => descriptor.term).filter((term) => !variation.featured.includes(term)),
|
|
653
|
+
...(variation.bpm ? { bpm: variation.bpm } : {}), earlier: [...ending.names],
|
|
654
|
+
}, { signal: AbortSignal.any([timeout, ending.controller.signal]) }),
|
|
655
|
+
new Promise((resolve) => { timeout.addEventListener("abort", () => resolve(null), { once: true }); }),
|
|
656
|
+
]);
|
|
657
|
+
const text = typeof name === "string" ? name.trim() : "";
|
|
658
|
+
return text && !ending.names.some((earlier) => earlier.toLocaleLowerCase() === text.toLocaleLowerCase()) ? text : numbered;
|
|
659
|
+
} catch {
|
|
660
|
+
return numbered;
|
|
661
|
+
}
|
|
526
662
|
};
|
|
527
663
|
// The tracks that started playing keep their audio, the newest few; the rest keep their receipts.
|
|
528
664
|
const prune = (current) => {
|
|
@@ -564,8 +700,12 @@ export function createAtmosphereStream({ generator, player, now = Date.now, keep
|
|
|
564
700
|
const current = stream;
|
|
565
701
|
if (!current || current.making || current.next || !fits(current)) return;
|
|
566
702
|
current.making = true;
|
|
703
|
+
changed();
|
|
567
704
|
try {
|
|
568
|
-
const { prompt, title, receipt, variation } = trackFor(current, current.made.length);
|
|
705
|
+
const { prompt, title: numbered, receipt, variation } = trackFor(current, current.made.length);
|
|
706
|
+
const title = await nameFor(current, variation, numbered);
|
|
707
|
+
if (stream !== current) return;
|
|
708
|
+
current.names.push(title);
|
|
569
709
|
const made = await generator.generate({ prompt, seconds: current.seconds, title, receipt }, { signal: current.controller.signal });
|
|
570
710
|
if (stream !== current) return;
|
|
571
711
|
const file = await generator.trackFile(made.id);
|
|
@@ -574,6 +714,8 @@ export function createAtmosphereStream({ generator, player, now = Date.now, keep
|
|
|
574
714
|
if (current.waiting && expired(current)) { end("time"); return; }
|
|
575
715
|
current.made.push({ id: made.id, featured: variation.featured, started: false, seconds: made.duration_seconds });
|
|
576
716
|
current.next = made.id;
|
|
717
|
+
// Made, so what the player reports from here on sees the stream as only playing.
|
|
718
|
+
current.making = false;
|
|
577
719
|
const track = { id: made.id, title: made.title, seconds: made.duration_seconds, file };
|
|
578
720
|
// A queue that ran dry while this was made starts again with it.
|
|
579
721
|
if (current.waiting) { current.waiting = false; player.playGenerated(track); } else player.enqueueGenerated(track);
|
|
@@ -583,6 +725,52 @@ export function createAtmosphereStream({ generator, player, now = Date.now, keep
|
|
|
583
725
|
if (stream === current && error?.code !== "generation_stopped") end("failed");
|
|
584
726
|
} finally {
|
|
585
727
|
current.making = false;
|
|
728
|
+
changed();
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
// What the room's player reports moves the stream along or ends it.
|
|
732
|
+
const move = (event) => {
|
|
733
|
+
const current = stream;
|
|
734
|
+
// Until its first track plays, what the player reports is about the music before it.
|
|
735
|
+
if (!current || !current.current) return;
|
|
736
|
+
const track = event?.track;
|
|
737
|
+
const ours = (id) => current.made.some((made) => made.id === id);
|
|
738
|
+
// A queue or a song that is not one of the stream's tracks is other music, which ends the stream.
|
|
739
|
+
if (event?.type === "queue" || event?.type === "playing") {
|
|
740
|
+
const items = Array.isArray(event.tracks) ? event.tracks : track ? [track] : [];
|
|
741
|
+
if (items.some((item) => !item?.generated || (item.id && !ours(item.id)))) { end("other_music"); return; }
|
|
742
|
+
}
|
|
743
|
+
const isCurrent = track?.id && track.id === current.current.id;
|
|
744
|
+
if (event?.type === "playing" && track?.id && track.id === current.next) {
|
|
745
|
+
// The window may already have moved on to it when the time limit had passed. The generator
|
|
746
|
+
// has begun counting it, and Moondog's own stop must not read as the listener's skip.
|
|
747
|
+
if (expired(current)) { generator.discard?.(track.id); endInTime(); return; }
|
|
748
|
+
leftEarly(current);
|
|
749
|
+
const made = current.made.find((entry) => entry.id === track.id);
|
|
750
|
+
current.current = { id: track.id, heardMs: 0, since: now(), finished: false, seconds: made?.seconds };
|
|
751
|
+
if (made) made.started = true;
|
|
752
|
+
current.next = null;
|
|
753
|
+
current.waiting = false;
|
|
754
|
+
prune(current);
|
|
755
|
+
void makeNext();
|
|
756
|
+
} else if (event?.type === "paused" && isCurrent && current.current.since !== null) {
|
|
757
|
+
current.current.heardMs += now() - current.current.since;
|
|
758
|
+
current.current.since = null;
|
|
759
|
+
} else if (event?.type === "playing" && isCurrent && current.current.since === null) {
|
|
760
|
+
current.current.since = now();
|
|
761
|
+
} else if (event?.type === "skipped" && isCurrent) {
|
|
762
|
+
// A skip past the end of the queue moves on to the next track once it is made.
|
|
763
|
+
leftEarly(current);
|
|
764
|
+
current.current.finished = true;
|
|
765
|
+
if (!current.next) ranDry(current);
|
|
766
|
+
else if (expired(current)) endInTime();
|
|
767
|
+
} else if ((event?.type === "finished" || event?.type === "failed") && isCurrent) {
|
|
768
|
+
// A track that could not play says nothing about the listener; it is passed over like one that ended.
|
|
769
|
+
current.current.finished = true;
|
|
770
|
+
if (!current.next) ranDry(current);
|
|
771
|
+
else if (expired(current)) endInTime();
|
|
772
|
+
} else if (event?.type === "stopped" && !current.waiting && !current.starting) {
|
|
773
|
+
end("stopped");
|
|
586
774
|
}
|
|
587
775
|
};
|
|
588
776
|
return {
|
|
@@ -593,17 +781,25 @@ export function createAtmosphereStream({ generator, player, now = Date.now, keep
|
|
|
593
781
|
return { tracks_made: stream.made.length, started_at: new Date(stream.startedAt).toISOString(),
|
|
594
782
|
ends_at: new Date(stream.endsAt).toISOString(), steering_away_from: [...stream.leaveOut] };
|
|
595
783
|
},
|
|
784
|
+
/** For the surfaces: { phase, started_at, ends_at, tracks_made }, or null when no stream runs. */
|
|
785
|
+
view: () => viewOf(stream),
|
|
786
|
+
/** Hears { view, ended } once for each change: the start, each phase, each track made, and the end with its reason. */
|
|
787
|
+
subscribe(listener) {
|
|
788
|
+
listeners.add(listener);
|
|
789
|
+
return () => listeners.delete(listener);
|
|
790
|
+
},
|
|
596
791
|
/**
|
|
597
792
|
* Starts a stream from a compiled taste and plays its first track; the next is made at once.
|
|
598
793
|
* 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
|
|
794
|
+
* play. Returns the first track. A stream already running gives way to the new one, and its track
|
|
795
|
+
* plays on until the first new one takes over, so nothing goes silent while that is made.
|
|
600
796
|
*/
|
|
601
|
-
async start({ compiled, title, seconds, minutes, receipt = {} }, { signal, onProgress, onTrack, beforePlay } = {}) {
|
|
602
|
-
|
|
603
|
-
if (previous?.current) player.stop?.();
|
|
797
|
+
async start({ compiled, title, seconds, minutes, receipt = {} }, { signal, onProgress, onTrack, beforePlay, nameTrack } = {}) {
|
|
798
|
+
end("replaced");
|
|
604
799
|
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() };
|
|
800
|
+
made: [], names: [title], nameTrack, leaveOut: new Set(), current: null, next: null, waiting: false, making: true, controller: new AbortController() };
|
|
606
801
|
stream = current;
|
|
802
|
+
changed();
|
|
607
803
|
const stopped = () => Object.assign(new Error("The stream was stopped before it began."), { code: "generation_stopped" });
|
|
608
804
|
try {
|
|
609
805
|
const { prompt, title: first, receipt: firstReceipt, variation } = trackFor(current, 0);
|
|
@@ -617,67 +813,43 @@ export function createAtmosphereStream({ generator, player, now = Date.now, keep
|
|
|
617
813
|
current.made.push({ id: made.id, featured: variation.featured, started: true, seconds: made.duration_seconds });
|
|
618
814
|
current.current = { id: made.id, heardMs: 0, since: now(), finished: false, seconds: made.duration_seconds };
|
|
619
815
|
current.making = false;
|
|
816
|
+
// The made track it replaces, a single one or the last stream's, was cut off by Moondog, not
|
|
817
|
+
// left by the listener: counted as a skip, it would steer later tracks away from its sound.
|
|
818
|
+
generator.discard?.();
|
|
620
819
|
// Whatever was playing stops for the first track; that stop is not the listener's.
|
|
621
820
|
current.starting = true;
|
|
622
821
|
try { player.playGenerated({ id: made.id, title: made.title, seconds: made.duration_seconds, file }); } finally { current.starting = false; }
|
|
822
|
+
heldOver = null;
|
|
623
823
|
onTrack?.(made);
|
|
624
824
|
void makeNext();
|
|
825
|
+
changed();
|
|
625
826
|
return { made, prompt, variation };
|
|
626
827
|
} catch (error) {
|
|
627
|
-
|
|
828
|
+
// A reply stopped while the first track was made is the listener's own stop, not a failure.
|
|
829
|
+
if (stream === current) end(error?.code === "generation_stopped" ? "asked" : "failed");
|
|
830
|
+
// Why it ended tells the listener turning it off apart from a stop of the reply.
|
|
831
|
+
if (error?.code === "generation_stopped" && error.ended === undefined) error.ended = current.ended ?? null;
|
|
628
832
|
throw error;
|
|
629
833
|
}
|
|
630
834
|
},
|
|
631
835
|
stop(reason = "asked") {
|
|
836
|
+
const held = heldOver;
|
|
632
837
|
const ending = end(reason);
|
|
633
|
-
// Before its first track plays, the player still holds the music from before the stream, which
|
|
634
|
-
|
|
838
|
+
// Before its first track plays, the player still holds the music from before the stream, which
|
|
839
|
+
// stays, unless that is the track of the stream it replaced. A stream replaced by Moondog's next
|
|
840
|
+
// track or stream plays on until that one takes over.
|
|
841
|
+
if (ending && reason !== "replaced" && (ending.current || held)) player.stop?.();
|
|
635
842
|
return ending ? { tracks_made: ending.made.length } : null;
|
|
636
843
|
},
|
|
637
844
|
/** What the room's player reports, which moves the stream along or ends it. */
|
|
638
845
|
observe(event) {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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");
|
|
846
|
+
// Other music, or a stop, means the player no longer holds the replaced stream's tracks.
|
|
847
|
+
if (heldOver) {
|
|
848
|
+
const items = Array.isArray(event?.tracks) ? event.tracks : event?.track ? [event.track] : [];
|
|
849
|
+
if (event?.type === "stopped" || ((event?.type === "queue" || event?.type === "playing") && items.some((item) => !heldOver.has(item?.id)))) heldOver = null;
|
|
680
850
|
}
|
|
851
|
+
move(event);
|
|
852
|
+
changed();
|
|
681
853
|
},
|
|
682
854
|
};
|
|
683
855
|
}
|