@audiofool/moondog 0.2.1 → 0.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/NOTICE +12 -0
  2. package/README.md +3 -2
  3. package/licenses/stability-ai-community-license.md +58 -0
  4. package/package.json +3 -1
  5. package/scripts/moondog.mjs +38 -2
  6. package/src/core/apple-projection-domain-services.mjs +14 -0
  7. package/src/core/capability-catalog.mjs +55 -0
  8. package/src/core/listening-profile-domain-services.mjs +12 -0
  9. package/src/core/moondog-application.mjs +195 -2
  10. package/src/i18n/messages/de.mjs +2 -0
  11. package/src/i18n/messages/en.mjs +2 -0
  12. package/src/i18n/messages/es.mjs +2 -0
  13. package/src/i18n/messages/fr.mjs +2 -0
  14. package/src/i18n/messages/it.mjs +2 -0
  15. package/src/i18n/messages/ja.mjs +2 -0
  16. package/src/i18n/messages/ko.mjs +2 -0
  17. package/src/i18n/messages/pt.mjs +2 -0
  18. package/src/i18n/messages/zh-Hant.mjs +2 -0
  19. package/src/i18n/messages/zh.mjs +2 -0
  20. package/src/i18n/screen/es.mjs +26 -0
  21. package/src/i18n/screen/ja.mjs +26 -0
  22. package/src/i18n/screen/pt.mjs +26 -0
  23. package/src/i18n/screen/zh.mjs +26 -0
  24. package/src/integrations/apple-music/terminal-preview-player.mjs +38 -11
  25. package/src/integrations/spotify/authentication.mjs +23 -18
  26. package/src/integrations/stable-audio/local-generator.mjs +683 -0
  27. package/src/profile/moondog-suggestions.mjs +9 -0
  28. package/src/profile/taste-compiler.mjs +197 -0
  29. package/src/runtime/pi/agent-runtime.mjs +278 -9
  30. package/src/runtime/pi/persistent-credential-store.mjs +6 -28
  31. package/src/surfaces/cli/command-completions.mjs +1 -0
  32. package/src/surfaces/cli/format-output.mjs +1 -0
  33. package/src/surfaces/cli/tui.mjs +70 -8
@@ -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
- const controls = ` ${position}/${count}${state ? ` · ${state}` : ""} · ${apple} · /skip · /stop`;
480
- const song = sanitizeTerminalText(`${track.title} · ${track.artist_credit}`).replace(/\s+/gu, " ").trim();
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
- if (event?.type === "queue") addPreviewNote(event);
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
- const acted = command === "stop" ? previewPlayer.stop() : previewPlayer.skip();
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
- void firstRun.finish({ skipped: true }).catch(() => {});
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
  }