@audiofool/moondog 0.2.2 → 0.3.0-beta.2
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 +118 -3
- 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 +244 -11
- package/src/i18n/messages/de.mjs +3 -0
- package/src/i18n/messages/en.mjs +4 -0
- package/src/i18n/messages/es.mjs +3 -0
- package/src/i18n/messages/fr.mjs +3 -0
- package/src/i18n/messages/it.mjs +3 -0
- package/src/i18n/messages/ja.mjs +3 -0
- package/src/i18n/messages/ko.mjs +3 -0
- package/src/i18n/messages/pt.mjs +3 -0
- package/src/i18n/messages/zh-Hant.mjs +3 -0
- package/src/i18n/messages/zh.mjs +3 -0
- package/src/i18n/screen/es.mjs +33 -0
- package/src/i18n/screen/ja.mjs +33 -0
- package/src/i18n/screen/pt.mjs +33 -0
- package/src/i18n/screen/zh.mjs +33 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +40 -11
- package/src/integrations/netease/ncm-cli-player.mjs +12 -6
- package/src/integrations/stable-audio/local-generator.mjs +758 -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/surfaces/cli/command-completions.mjs +1 -0
- package/src/surfaces/cli/format-output.mjs +1 -0
- package/src/surfaces/cli/tui.mjs +138 -17
package/src/surfaces/cli/tui.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { LOCALES, N_, intlLocale, liveScreenTranslator, normalizeLocale, translator } from "../../i18n/index.mjs";
|
|
2
3
|
import {
|
|
3
4
|
CombinedAutocompleteProvider,
|
|
4
5
|
Container,
|
|
@@ -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,8 @@ 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") },
|
|
129
|
+
{ name: "onair", description: N_("Go on air: Moondog keeps making music from your taste") },
|
|
123
130
|
{ name: "spotify", description: N_("Connect and control Spotify") },
|
|
124
131
|
{ name: "skip", description: N_("Skip to the next song preview") },
|
|
125
132
|
{ name: "stop", description: N_("Stop the song previews") },
|
|
@@ -223,6 +230,8 @@ export async function runMoondogTui({
|
|
|
223
230
|
// The terminal's preview player. The room shows what it plays (subscribe) and
|
|
224
231
|
// gives the listener /skip and /stop.
|
|
225
232
|
previewPlayer,
|
|
233
|
+
// Making music on this Mac with Stable Audio 3: { status, check, save }, or nothing where the room can't play it.
|
|
234
|
+
generation,
|
|
226
235
|
environment = process.env,
|
|
227
236
|
}) {
|
|
228
237
|
if (providers === listPiProviders && models === listPiModels) {
|
|
@@ -238,7 +247,10 @@ export async function runMoondogTui({
|
|
|
238
247
|
// /skip and /stop exist only where the room controls the previews itself.
|
|
239
248
|
const previewControls = typeof previewPlayer?.subscribe === "function" &&
|
|
240
249
|
typeof previewPlayer.skip === "function" && typeof previewPlayer.stop === "function";
|
|
241
|
-
|
|
250
|
+
// /onair needs both: the room's own player and music making here.
|
|
251
|
+
const onAirControls = previewControls && typeof generation?.save === "function";
|
|
252
|
+
const commands = slashCommands.filter(({ name }) => (previewControls || !["skip", "stop"].includes(name)) &&
|
|
253
|
+
(name !== "generation" || typeof generation?.save === "function") && (name !== "onair" || onAirControls));
|
|
242
254
|
const localizedCommands = commands.map(({ name, description }) => ({ name, get description() { return tr.marked(description); } }));
|
|
243
255
|
let activeRuntime = runtime;
|
|
244
256
|
application.startNewSession?.("tui_start");
|
|
@@ -282,6 +294,8 @@ export async function runMoondogTui({
|
|
|
282
294
|
let firstRunNote = null;
|
|
283
295
|
let firstRunSetup = null;
|
|
284
296
|
let firstRunTicket = 0;
|
|
297
|
+
// The skip being saved, which the room waits for before it closes.
|
|
298
|
+
let firstRunSaving = null;
|
|
285
299
|
let stopWatchingFirstRun = null;
|
|
286
300
|
let importPage = "start";
|
|
287
301
|
let importReturnHome = false;
|
|
@@ -315,7 +329,20 @@ export async function runMoondogTui({
|
|
|
315
329
|
: Math.min(messageQueue.length, 4) + (messageQueue.length > 4 ? 1 : 0);
|
|
316
330
|
// The preview now playing sits on its own line above the draft.
|
|
317
331
|
let nowPlaying = null;
|
|
318
|
-
|
|
332
|
+
// Moondog's stream as the core shows it: null, or { phase, started_at, ends_at, tracks_made }.
|
|
333
|
+
let onAir = application.streamView?.() ?? null;
|
|
334
|
+
// Between two of the stream's tracks nothing plays, and the line says the next one is being made.
|
|
335
|
+
const dryGap = () => !nowPlaying && onAir?.phase === "waiting";
|
|
336
|
+
const nowPlayingRows = () => ((nowPlaying || dryGap()) && !profileView && !importView && !firstRunView ? 1 : 0);
|
|
337
|
+
// When the stream ends at the latest, as a clock time: an upper bound, so never a countdown. The clock
|
|
338
|
+
// follows the screen's language, as the words around it do.
|
|
339
|
+
const onAirUntil = () => {
|
|
340
|
+
const ends = new Date(onAir?.ends_at);
|
|
341
|
+
let time;
|
|
342
|
+
try { time = new Intl.DateTimeFormat(intlLocale(tr.locale), { hour: "numeric", minute: "2-digit" }).format(ends); }
|
|
343
|
+
catch { time = ends.toTimeString().slice(0, 5); }
|
|
344
|
+
return tr("on air until {time}", { time });
|
|
345
|
+
};
|
|
319
346
|
let cancelWork = null;
|
|
320
347
|
let cancellationRequested = false;
|
|
321
348
|
let workStartedAt = 0;
|
|
@@ -470,14 +497,21 @@ export async function runMoondogTui({
|
|
|
470
497
|
render(width) {
|
|
471
498
|
if (profileView || importView || firstRunView) return [];
|
|
472
499
|
const lines = [];
|
|
473
|
-
if (nowPlaying) {
|
|
474
|
-
const { track, position, count, type } = nowPlaying;
|
|
500
|
+
if (nowPlaying || dryGap()) {
|
|
501
|
+
const { track, position, count, type } = nowPlaying ?? {};
|
|
475
502
|
const prefix = ` ${theme.plain ? ">" : "♪"} `;
|
|
476
503
|
// Where the terminal can't open links, the note in the conversation shows the address.
|
|
477
|
-
const apple = getCapabilities().hyperlinks ? hyperlink("Apple Music", track.catalog_url) : "Apple Music";
|
|
504
|
+
const apple = nowPlaying && getCapabilities().hyperlinks ? hyperlink("Apple Music", track.catalog_url) : "Apple Music";
|
|
478
505
|
const state = type === "loading" ? tr("loading") : type === "paused" ? tr("paused") : "";
|
|
479
|
-
|
|
480
|
-
const
|
|
506
|
+
// Once its first track plays, a made track is the stream's, which goes on to the next with /skip.
|
|
507
|
+
const onAirTrack = track?.generated && onAir && onAir.phase !== "starting";
|
|
508
|
+
// A track Moondog made is its own record, kept on this Mac, with nothing to open elsewhere.
|
|
509
|
+
const controls = !nowPlaying ? ` · ${onAirUntil()} · /stop`
|
|
510
|
+
: onAirTrack ? `${state ? ` ${state}` : ""} · ${onAirUntil()} · /skip · /stop`
|
|
511
|
+
: track.generated ? `${state ? ` ${state}` : ""} · /stop`
|
|
512
|
+
: ` ${position}/${count}${state ? ` · ${state}` : ""} · ${apple} · /skip · /stop`;
|
|
513
|
+
const song = !nowPlaying ? tr("Cutting the next record")
|
|
514
|
+
: sanitizeTerminalText(`${track.title} · ${track.generated ? tr("Made by Moondog") : track.artist_credit}`).replace(/\s+/gu, " ").trim();
|
|
481
515
|
const room = width - visibleWidth(prefix) - visibleWidth(controls);
|
|
482
516
|
lines.push(paintBrandLine(room >= 12
|
|
483
517
|
? theme.accent(prefix) + theme.text(truncateToWidth(song, room, "…")) + theme.faint(controls)
|
|
@@ -702,18 +736,82 @@ export async function runMoondogTui({
|
|
|
702
736
|
};
|
|
703
737
|
const stopWatchingPreviews = previewControls ? previewPlayer.subscribe((event) => {
|
|
704
738
|
if (cleanedUp) return;
|
|
705
|
-
|
|
739
|
+
// A track Moondog made needs no preview note: the reply names it, and the host's line says where it was made.
|
|
740
|
+
if (event?.type === "queue") { if (!event.tracks?.some((track) => track.generated)) addPreviewNote(event); }
|
|
706
741
|
else if (event?.type === "failed") markPreviewFailed(event);
|
|
707
742
|
else if (["loading", "playing", "paused"].includes(event?.type)) nowPlaying = event;
|
|
708
743
|
else if (event?.type === "stopped") nowPlaying = null;
|
|
709
744
|
tui.requestRender();
|
|
710
745
|
}) : null;
|
|
746
|
+
// The stream redraws the line above the draft as it moves on. Ending by itself, it goes dark
|
|
747
|
+
// without a word; only a track that couldn't be made says so.
|
|
748
|
+
const stopWatchingStream = application.subscribeStream?.(({ view, ended } = {}) => {
|
|
749
|
+
if (cleanedUp) return;
|
|
750
|
+
onAir = view ?? null;
|
|
751
|
+
if (ended === "failed") setFooter(tr("Off air: the next record couldn't be made."), failure);
|
|
752
|
+
tui.requestRender();
|
|
753
|
+
}) ?? null;
|
|
711
754
|
// These act at once, even while Moondog is answering.
|
|
712
755
|
const controlPreviews = (command) => {
|
|
713
|
-
|
|
714
|
-
|
|
756
|
+
// /stop also ends Moondog's stream while its next track is still being made and nothing plays.
|
|
757
|
+
const streamStopped = command === "stop" && application.stopStream?.()?.state === "stopped";
|
|
758
|
+
const acted = command === "stop" ? previewPlayer.stop() || streamStopped : previewPlayer.skip();
|
|
759
|
+
const waiting = !acted && command === "skip" && application.streamView?.()?.phase === "waiting";
|
|
760
|
+
// Off air shows even during a reply: a stream stopped while its first track is made has no row to lose.
|
|
761
|
+
if (!busy || streamStopped) {
|
|
762
|
+
setFooter(waiting ? tr("The next record is still being cut.") : !acted ? tr("No previews are playing.")
|
|
763
|
+
: streamStopped ? tr("Off air.") : command === "stop" ? tr("Previews stopped.") : tr("Skipped."));
|
|
764
|
+
}
|
|
715
765
|
tui.requestRender();
|
|
716
766
|
};
|
|
767
|
+
// /onair goes off air at once while a stream runs, even while Moondog is answering, and says so
|
|
768
|
+
// when music making is off. Otherwise it returns the request to send, in the reply's language.
|
|
769
|
+
const onAirRequest = () => {
|
|
770
|
+
if (application.streamView?.()) {
|
|
771
|
+
application.stopStream();
|
|
772
|
+
// Said even during a reply: a stream stopped while its first track is made has no line to clear.
|
|
773
|
+
setFooter(tr("Off air."));
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
// Music making can be turned off, here or in the window, after the room opened.
|
|
777
|
+
if (!application.streamReady?.()) {
|
|
778
|
+
setFooter(tr("Turn on music making first: /generation on."), warning);
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
return translator(application.locale)("onAir.request");
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
// Making music on this Mac. /generation says where it stands; /generation on [folder] turns it on,
|
|
785
|
+
// with the folder of Stability AI's MLX build when it is not the usual one; /generation off turns it off.
|
|
786
|
+
const home = homedir();
|
|
787
|
+
const shownFolder = (folder) => sanitizeTerminalText(String(folder ?? "")).replace(home, "~");
|
|
788
|
+
const generationMessage = (status) => {
|
|
789
|
+
const folder = shownFolder(status?.path);
|
|
790
|
+
if (status?.install === "unsupported") return tr("Stable Audio 3 needs a Mac with Apple silicon, so Moondog can't make music here.");
|
|
791
|
+
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.");
|
|
792
|
+
if (status?.state === "off") {
|
|
793
|
+
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 });
|
|
794
|
+
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 });
|
|
795
|
+
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 });
|
|
796
|
+
}
|
|
797
|
+
if (status?.state === "incomplete") {
|
|
798
|
+
return status.missing === "weights"
|
|
799
|
+
? tr("Stable Audio 3 at {folder} doesn't have the Medium model yet. Run ./install.sh --download medium there, then use /generation again.", { folder })
|
|
800
|
+
: tr("Stable Audio 3 at {folder} isn't installed yet. Run ./install.sh --download medium there, then use /generation again.", { folder });
|
|
801
|
+
}
|
|
802
|
+
return tr("Stable Audio 3 isn't at {folder}. Use /generation on with the folder that holds its sa3 script.", { folder });
|
|
803
|
+
};
|
|
804
|
+
const manageGeneration = async (args) => {
|
|
805
|
+
const [action, ...rest] = args;
|
|
806
|
+
if ((action && !["on", "off"].includes(action)) || (action === "off" && rest.length)) {
|
|
807
|
+
throw new Error(tr("Use /generation, /generation on [folder], or /generation off."));
|
|
808
|
+
}
|
|
809
|
+
const status = action ? await generation.save({ enabled: action === "on", ...(rest.length ? { path: rest.join(" ") } : {}) })
|
|
810
|
+
: await generation.check();
|
|
811
|
+
addMoondogMessage(generationMessage(status));
|
|
812
|
+
setFooter(status.state === "ready" ? tr("Music making on.") : status.state === "off" ? tr("Music making off.") : tr("Music making isn't ready."),
|
|
813
|
+
status.state === "ready" || status.state === "off" ? dim : warning);
|
|
814
|
+
};
|
|
717
815
|
|
|
718
816
|
const removeLifecycleListeners = () => {
|
|
719
817
|
signalTarget.removeListener("SIGTERM", handleSigterm);
|
|
@@ -732,6 +830,7 @@ export async function runMoondogTui({
|
|
|
732
830
|
stopWatchingModel?.();
|
|
733
831
|
stopWatchingFirstRun?.();
|
|
734
832
|
stopWatchingPreviews?.();
|
|
833
|
+
stopWatchingStream?.();
|
|
735
834
|
clearInterval(indicator);
|
|
736
835
|
indicator = null;
|
|
737
836
|
clearInterval(homeMotion);
|
|
@@ -760,6 +859,8 @@ export async function runMoondogTui({
|
|
|
760
859
|
};
|
|
761
860
|
|
|
762
861
|
const shutdown = () => {
|
|
862
|
+
// A reply still running stops with the room, and so does a track it is making.
|
|
863
|
+
try { activeRuntime.abort(); } catch { /* Nothing was running. */ }
|
|
763
864
|
cleanup();
|
|
764
865
|
// The spoken line that closes Eclipse, the last thing on The Dark Side of the Moon.
|
|
765
866
|
try {
|
|
@@ -1804,10 +1905,11 @@ export async function runMoondogTui({
|
|
|
1804
1905
|
? tr("All set. /spotify help shows how to connect Spotify.")
|
|
1805
1906
|
: tr("All set. Say anything, or press Tab to look around."), success);
|
|
1806
1907
|
};
|
|
1807
|
-
// The view closes at once; the saved skip then reaches the window too
|
|
1908
|
+
// The view closes at once; the saved skip then reaches the window too, and a room closed right
|
|
1909
|
+
// after Esc still saves it.
|
|
1808
1910
|
const skipFirstRun = () => {
|
|
1809
1911
|
closeFirstRun(tr("Skipped. /service and /model are here when you want them."));
|
|
1810
|
-
|
|
1912
|
+
firstRunSaving = firstRun.finish({ skipped: true }).catch(() => {});
|
|
1811
1913
|
};
|
|
1812
1914
|
const handleFirstRunAction = async ({ type, value } = {}) => {
|
|
1813
1915
|
if (busy || cleanedUp || !firstRunView) return;
|
|
@@ -2144,6 +2246,10 @@ export async function runMoondogTui({
|
|
|
2144
2246
|
await pickService(args);
|
|
2145
2247
|
return;
|
|
2146
2248
|
}
|
|
2249
|
+
if (command === "generation") {
|
|
2250
|
+
await manageGeneration(args);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2147
2253
|
if (command === "spotify") {
|
|
2148
2254
|
await runSpotifyCommand(args);
|
|
2149
2255
|
return;
|
|
@@ -2303,6 +2409,12 @@ export async function runMoondogTui({
|
|
|
2303
2409
|
await openCommandPalette();
|
|
2304
2410
|
return;
|
|
2305
2411
|
}
|
|
2412
|
+
// Going on air is a request like any other, which the agent answers by starting the stream.
|
|
2413
|
+
if (command === "onair") {
|
|
2414
|
+
const request = onAirRequest();
|
|
2415
|
+
if (request) await submitListeningTurn(request);
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2306
2418
|
try {
|
|
2307
2419
|
if (!["home", "theme", "art", "motion", "model", "service", "resume", "rewind", "branch", "new", "taste", "profile", "import"].includes(command)) enterConversation();
|
|
2308
2420
|
if (command !== "auth") editor.addToHistory(value);
|
|
@@ -2312,7 +2424,7 @@ export async function runMoondogTui({
|
|
|
2312
2424
|
setFooter(`/${command}...`);
|
|
2313
2425
|
const argumentText = value.slice(1).trim().slice(rawCommand.length).trim();
|
|
2314
2426
|
const parsedArgs = command === "import" ? argumentText ? [argumentText] : []
|
|
2315
|
-
: ["profile", "spotify", "web"].includes(command)
|
|
2427
|
+
: ["profile", "spotify", "web", "generation"].includes(command)
|
|
2316
2428
|
? parseTuiArguments(argumentText)
|
|
2317
2429
|
: args;
|
|
2318
2430
|
await runLocalCommand(command, parsedArgs);
|
|
@@ -2408,6 +2520,12 @@ export async function runMoondogTui({
|
|
|
2408
2520
|
if (!cancellationRequested) setFooter(tr("Can't reach the model. Trying again ({attempt}/{max})...", { attempt, max: maxRetries }), warning);
|
|
2409
2521
|
},
|
|
2410
2522
|
onToolStart: (tool) => updateTool(tool, "running"),
|
|
2523
|
+
// Making a track says where it is, from warming up to saving it.
|
|
2524
|
+
onToolUpdate: (tool) => {
|
|
2525
|
+
const stage = generationStages[tool?.stage];
|
|
2526
|
+
if (cleanedUp || cancellationRequested || !stage) return;
|
|
2527
|
+
setFooter(`${sanitizeTerminalText(tr.marked(tool.label ?? "Music tool"))} · ${tr.marked(stage)}...`);
|
|
2528
|
+
},
|
|
2411
2529
|
onToolEnd: (tool) => updateTool(tool, tool?.isError ? "failed" : "completed"),
|
|
2412
2530
|
// The model names a new conversation after its first reply; the header shows that name from then on.
|
|
2413
2531
|
onConversationNamed: ({ session_id, title }) => {
|
|
@@ -2484,9 +2602,11 @@ export async function runMoondogTui({
|
|
|
2484
2602
|
controlPreviews(pending.slice(1));
|
|
2485
2603
|
return;
|
|
2486
2604
|
}
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2605
|
+
// /onair stops a stream at once; the request to go on air waits in the queue like any message.
|
|
2606
|
+
const queued = onAirControls && pending === "/onair" ? onAirRequest() : pending;
|
|
2607
|
+
if (queued) {
|
|
2608
|
+
if (messageQueue.length >= 30) editor.setText(queued);
|
|
2609
|
+
else messageQueue.push(queued);
|
|
2490
2610
|
if (rewindPending) queuePaused = true;
|
|
2491
2611
|
}
|
|
2492
2612
|
if (!cleanedUp) tui.requestRender();
|
|
@@ -2739,6 +2859,7 @@ export async function runMoondogTui({
|
|
|
2739
2859
|
} finally {
|
|
2740
2860
|
cleanup();
|
|
2741
2861
|
await lyricTask;
|
|
2862
|
+
await firstRunSaving;
|
|
2742
2863
|
}
|
|
2743
2864
|
return exitRequest;
|
|
2744
2865
|
}
|