@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.
- package/NOTICE +12 -0
- package/README.md +3 -2
- package/licenses/stability-ai-community-license.md +58 -0
- package/package.json +3 -1
- package/scripts/moondog.mjs +38 -2
- package/src/core/apple-projection-domain-services.mjs +14 -0
- package/src/core/capability-catalog.mjs +55 -0
- package/src/core/listening-profile-domain-services.mjs +12 -0
- package/src/core/moondog-application.mjs +195 -2
- package/src/i18n/messages/de.mjs +2 -0
- package/src/i18n/messages/en.mjs +2 -0
- package/src/i18n/messages/es.mjs +2 -0
- package/src/i18n/messages/fr.mjs +2 -0
- package/src/i18n/messages/it.mjs +2 -0
- package/src/i18n/messages/ja.mjs +2 -0
- package/src/i18n/messages/ko.mjs +2 -0
- package/src/i18n/messages/pt.mjs +2 -0
- package/src/i18n/messages/zh-Hant.mjs +2 -0
- package/src/i18n/messages/zh.mjs +2 -0
- package/src/i18n/screen/es.mjs +26 -0
- package/src/i18n/screen/ja.mjs +26 -0
- package/src/i18n/screen/pt.mjs +26 -0
- package/src/i18n/screen/zh.mjs +26 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +38 -11
- package/src/integrations/spotify/authentication.mjs +23 -18
- package/src/integrations/stable-audio/local-generator.mjs +683 -0
- package/src/profile/moondog-suggestions.mjs +9 -0
- package/src/profile/taste-compiler.mjs +197 -0
- package/src/runtime/pi/agent-runtime.mjs +278 -9
- package/src/runtime/pi/persistent-credential-store.mjs +6 -28
- package/src/surfaces/cli/command-completions.mjs +1 -0
- package/src/surfaces/cli/format-output.mjs +1 -0
- package/src/surfaces/cli/tui.mjs +70 -8
package/src/surfaces/cli/tui.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
1
2
|
import { LOCALES, N_, liveScreenTranslator, normalizeLocale } from "../../i18n/index.mjs";
|
|
2
3
|
import {
|
|
3
4
|
CombinedAutocompleteProvider,
|
|
@@ -53,6 +54,10 @@ import {
|
|
|
53
54
|
setBrandText,
|
|
54
55
|
} from "./brand-components.mjs";
|
|
55
56
|
|
|
57
|
+
// What making a track says in the footer, stage by stage (src/integrations/stable-audio).
|
|
58
|
+
const generationStages = { starting: N_("warming up"), reading: N_("reading your taste"), composing: N_("composing"),
|
|
59
|
+
decoding: N_("pressing the record"), writing: N_("saving it") };
|
|
60
|
+
|
|
56
61
|
// The lines Moondog shows, as a radio host, while the model thinks; the window has the same ones.
|
|
57
62
|
export const hostLines = (tr) => [tr("Reading the room"), tr("Checking the request line"), tr("Finding the groove"), tr("Working the decks"),
|
|
58
63
|
tr("Planning the segue"), tr("Mic check"), tr("Riding the faders"), tr("Finding the cue")];
|
|
@@ -120,6 +125,7 @@ const slashCommands = [
|
|
|
120
125
|
{ name: "endpoint", description: N_("Add a model service by its address") },
|
|
121
126
|
{ name: "web", description: N_("Look things up on public music sites") },
|
|
122
127
|
{ name: "service", description: N_("Choose where Moondog plays music") },
|
|
128
|
+
{ name: "generation", description: N_("Let Moondog make music on this Mac") },
|
|
123
129
|
{ name: "spotify", description: N_("Connect and control Spotify") },
|
|
124
130
|
{ name: "skip", description: N_("Skip to the next song preview") },
|
|
125
131
|
{ name: "stop", description: N_("Stop the song previews") },
|
|
@@ -223,6 +229,8 @@ export async function runMoondogTui({
|
|
|
223
229
|
// The terminal's preview player. The room shows what it plays (subscribe) and
|
|
224
230
|
// gives the listener /skip and /stop.
|
|
225
231
|
previewPlayer,
|
|
232
|
+
// Making music on this Mac with Stable Audio 3: { status, check, save }, or nothing where the room can't play it.
|
|
233
|
+
generation,
|
|
226
234
|
environment = process.env,
|
|
227
235
|
}) {
|
|
228
236
|
if (providers === listPiProviders && models === listPiModels) {
|
|
@@ -238,7 +246,8 @@ export async function runMoondogTui({
|
|
|
238
246
|
// /skip and /stop exist only where the room controls the previews itself.
|
|
239
247
|
const previewControls = typeof previewPlayer?.subscribe === "function" &&
|
|
240
248
|
typeof previewPlayer.skip === "function" && typeof previewPlayer.stop === "function";
|
|
241
|
-
const commands = slashCommands.filter(({ name }) => previewControls || !["skip", "stop"].includes(name))
|
|
249
|
+
const commands = slashCommands.filter(({ name }) => (previewControls || !["skip", "stop"].includes(name)) &&
|
|
250
|
+
(name !== "generation" || typeof generation?.save === "function"));
|
|
242
251
|
const localizedCommands = commands.map(({ name, description }) => ({ name, get description() { return tr.marked(description); } }));
|
|
243
252
|
let activeRuntime = runtime;
|
|
244
253
|
application.startNewSession?.("tui_start");
|
|
@@ -282,6 +291,8 @@ export async function runMoondogTui({
|
|
|
282
291
|
let firstRunNote = null;
|
|
283
292
|
let firstRunSetup = null;
|
|
284
293
|
let firstRunTicket = 0;
|
|
294
|
+
// The skip being saved, which the room waits for before it closes.
|
|
295
|
+
let firstRunSaving = null;
|
|
285
296
|
let stopWatchingFirstRun = null;
|
|
286
297
|
let importPage = "start";
|
|
287
298
|
let importReturnHome = false;
|
|
@@ -476,8 +487,10 @@ export async function runMoondogTui({
|
|
|
476
487
|
// Where the terminal can't open links, the note in the conversation shows the address.
|
|
477
488
|
const apple = getCapabilities().hyperlinks ? hyperlink("Apple Music", track.catalog_url) : "Apple Music";
|
|
478
489
|
const state = type === "loading" ? tr("loading") : type === "paused" ? tr("paused") : "";
|
|
479
|
-
|
|
480
|
-
const
|
|
490
|
+
// A track Moondog made is its own record, kept on this Mac, with nothing to open elsewhere.
|
|
491
|
+
const controls = track.generated ? `${state ? ` ${state}` : ""} · /stop`
|
|
492
|
+
: ` ${position}/${count}${state ? ` · ${state}` : ""} · ${apple} · /skip · /stop`;
|
|
493
|
+
const song = sanitizeTerminalText(`${track.title} · ${track.generated ? tr("Made by Moondog") : track.artist_credit}`).replace(/\s+/gu, " ").trim();
|
|
481
494
|
const room = width - visibleWidth(prefix) - visibleWidth(controls);
|
|
482
495
|
lines.push(paintBrandLine(room >= 12
|
|
483
496
|
? theme.accent(prefix) + theme.text(truncateToWidth(song, room, "…")) + theme.faint(controls)
|
|
@@ -702,7 +715,8 @@ export async function runMoondogTui({
|
|
|
702
715
|
};
|
|
703
716
|
const stopWatchingPreviews = previewControls ? previewPlayer.subscribe((event) => {
|
|
704
717
|
if (cleanedUp) return;
|
|
705
|
-
|
|
718
|
+
// A track Moondog made needs no preview note: the reply names it, and the host's line says where it was made.
|
|
719
|
+
if (event?.type === "queue") { if (!event.tracks?.some((track) => track.generated)) addPreviewNote(event); }
|
|
706
720
|
else if (event?.type === "failed") markPreviewFailed(event);
|
|
707
721
|
else if (["loading", "playing", "paused"].includes(event?.type)) nowPlaying = event;
|
|
708
722
|
else if (event?.type === "stopped") nowPlaying = null;
|
|
@@ -710,11 +724,45 @@ export async function runMoondogTui({
|
|
|
710
724
|
}) : null;
|
|
711
725
|
// These act at once, even while Moondog is answering.
|
|
712
726
|
const controlPreviews = (command) => {
|
|
713
|
-
|
|
727
|
+
// /stop also ends Moondog's stream while its next track is still being made and nothing plays.
|
|
728
|
+
const streamStopped = command === "stop" && application.stopStream?.()?.state === "stopped";
|
|
729
|
+
const acted = command === "stop" ? previewPlayer.stop() || streamStopped : previewPlayer.skip();
|
|
714
730
|
if (!busy) setFooter(!acted ? tr("No previews are playing.") : command === "stop" ? tr("Previews stopped.") : tr("Skipped."));
|
|
715
731
|
tui.requestRender();
|
|
716
732
|
};
|
|
717
733
|
|
|
734
|
+
// Making music on this Mac. /generation says where it stands; /generation on [folder] turns it on,
|
|
735
|
+
// with the folder of Stability AI's MLX build when it is not the usual one; /generation off turns it off.
|
|
736
|
+
const home = homedir();
|
|
737
|
+
const shownFolder = (folder) => sanitizeTerminalText(String(folder ?? "")).replace(home, "~");
|
|
738
|
+
const generationMessage = (status) => {
|
|
739
|
+
const folder = shownFolder(status?.path);
|
|
740
|
+
if (status?.install === "unsupported") return tr("Stable Audio 3 needs a Mac with Apple silicon, so Moondog can't make music here.");
|
|
741
|
+
if (status?.state === "ready") return tr("Moondog makes music on this Mac with Stable Audio 3, powered by Stability AI. Ask for a track. Stable Audio 3 runs offline, and the track stays on this Mac.");
|
|
742
|
+
if (status?.state === "off") {
|
|
743
|
+
if (status.install === "ready") return tr("Making music on this Mac is off. Stable Audio 3 is ready at {folder}; turn it on with /generation on.", { folder });
|
|
744
|
+
if (status.install === "incomplete") return tr("Making music on this Mac is off, and Stable Audio 3 at {folder} isn't fully installed. Run ./install.sh --download medium there, then use /generation on.", { folder });
|
|
745
|
+
return tr("Making music on this Mac is off, and Stable Audio 3 isn't at {folder}. Install Stability AI's MLX build from github.com/Stability-AI/stable-audio-3, then use /generation on with its folder.", { folder });
|
|
746
|
+
}
|
|
747
|
+
if (status?.state === "incomplete") {
|
|
748
|
+
return status.missing === "weights"
|
|
749
|
+
? tr("Stable Audio 3 at {folder} doesn't have the Medium model yet. Run ./install.sh --download medium there, then use /generation again.", { folder })
|
|
750
|
+
: tr("Stable Audio 3 at {folder} isn't installed yet. Run ./install.sh --download medium there, then use /generation again.", { folder });
|
|
751
|
+
}
|
|
752
|
+
return tr("Stable Audio 3 isn't at {folder}. Use /generation on with the folder that holds its sa3 script.", { folder });
|
|
753
|
+
};
|
|
754
|
+
const manageGeneration = async (args) => {
|
|
755
|
+
const [action, ...rest] = args;
|
|
756
|
+
if ((action && !["on", "off"].includes(action)) || (action === "off" && rest.length)) {
|
|
757
|
+
throw new Error(tr("Use /generation, /generation on [folder], or /generation off."));
|
|
758
|
+
}
|
|
759
|
+
const status = action ? await generation.save({ enabled: action === "on", ...(rest.length ? { path: rest.join(" ") } : {}) })
|
|
760
|
+
: await generation.check();
|
|
761
|
+
addMoondogMessage(generationMessage(status));
|
|
762
|
+
setFooter(status.state === "ready" ? tr("Music making on.") : status.state === "off" ? tr("Music making off.") : tr("Music making isn't ready."),
|
|
763
|
+
status.state === "ready" || status.state === "off" ? dim : warning);
|
|
764
|
+
};
|
|
765
|
+
|
|
718
766
|
const removeLifecycleListeners = () => {
|
|
719
767
|
signalTarget.removeListener("SIGTERM", handleSigterm);
|
|
720
768
|
signalTarget.removeListener("SIGHUP", handleSighup);
|
|
@@ -760,6 +808,8 @@ export async function runMoondogTui({
|
|
|
760
808
|
};
|
|
761
809
|
|
|
762
810
|
const shutdown = () => {
|
|
811
|
+
// A reply still running stops with the room, and so does a track it is making.
|
|
812
|
+
try { activeRuntime.abort(); } catch { /* Nothing was running. */ }
|
|
763
813
|
cleanup();
|
|
764
814
|
// The spoken line that closes Eclipse, the last thing on The Dark Side of the Moon.
|
|
765
815
|
try {
|
|
@@ -1804,10 +1854,11 @@ export async function runMoondogTui({
|
|
|
1804
1854
|
? tr("All set. /spotify help shows how to connect Spotify.")
|
|
1805
1855
|
: tr("All set. Say anything, or press Tab to look around."), success);
|
|
1806
1856
|
};
|
|
1807
|
-
// The view closes at once; the saved skip then reaches the window too
|
|
1857
|
+
// The view closes at once; the saved skip then reaches the window too, and a room closed right
|
|
1858
|
+
// after Esc still saves it.
|
|
1808
1859
|
const skipFirstRun = () => {
|
|
1809
1860
|
closeFirstRun(tr("Skipped. /service and /model are here when you want them."));
|
|
1810
|
-
|
|
1861
|
+
firstRunSaving = firstRun.finish({ skipped: true }).catch(() => {});
|
|
1811
1862
|
};
|
|
1812
1863
|
const handleFirstRunAction = async ({ type, value } = {}) => {
|
|
1813
1864
|
if (busy || cleanedUp || !firstRunView) return;
|
|
@@ -2144,6 +2195,10 @@ export async function runMoondogTui({
|
|
|
2144
2195
|
await pickService(args);
|
|
2145
2196
|
return;
|
|
2146
2197
|
}
|
|
2198
|
+
if (command === "generation") {
|
|
2199
|
+
await manageGeneration(args);
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2147
2202
|
if (command === "spotify") {
|
|
2148
2203
|
await runSpotifyCommand(args);
|
|
2149
2204
|
return;
|
|
@@ -2312,7 +2367,7 @@ export async function runMoondogTui({
|
|
|
2312
2367
|
setFooter(`/${command}...`);
|
|
2313
2368
|
const argumentText = value.slice(1).trim().slice(rawCommand.length).trim();
|
|
2314
2369
|
const parsedArgs = command === "import" ? argumentText ? [argumentText] : []
|
|
2315
|
-
: ["profile", "spotify", "web"].includes(command)
|
|
2370
|
+
: ["profile", "spotify", "web", "generation"].includes(command)
|
|
2316
2371
|
? parseTuiArguments(argumentText)
|
|
2317
2372
|
: args;
|
|
2318
2373
|
await runLocalCommand(command, parsedArgs);
|
|
@@ -2408,6 +2463,12 @@ export async function runMoondogTui({
|
|
|
2408
2463
|
if (!cancellationRequested) setFooter(tr("Can't reach the model. Trying again ({attempt}/{max})...", { attempt, max: maxRetries }), warning);
|
|
2409
2464
|
},
|
|
2410
2465
|
onToolStart: (tool) => updateTool(tool, "running"),
|
|
2466
|
+
// Making a track says where it is, from warming up to saving it.
|
|
2467
|
+
onToolUpdate: (tool) => {
|
|
2468
|
+
const stage = generationStages[tool?.stage];
|
|
2469
|
+
if (cleanedUp || cancellationRequested || !stage) return;
|
|
2470
|
+
setFooter(`${sanitizeTerminalText(tr.marked(tool.label ?? "Music tool"))} · ${tr.marked(stage)}...`);
|
|
2471
|
+
},
|
|
2411
2472
|
onToolEnd: (tool) => updateTool(tool, tool?.isError ? "failed" : "completed"),
|
|
2412
2473
|
// The model names a new conversation after its first reply; the header shows that name from then on.
|
|
2413
2474
|
onConversationNamed: ({ session_id, title }) => {
|
|
@@ -2739,6 +2800,7 @@ export async function runMoondogTui({
|
|
|
2739
2800
|
} finally {
|
|
2740
2801
|
cleanup();
|
|
2741
2802
|
await lyricTask;
|
|
2803
|
+
await firstRunSaving;
|
|
2742
2804
|
}
|
|
2743
2805
|
return exitRequest;
|
|
2744
2806
|
}
|