@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.
@@ -1,4 +1,5 @@
1
- import { LOCALES, N_, liveScreenTranslator, normalizeLocale } from "../../i18n/index.mjs";
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
- const commands = slashCommands.filter(({ name }) => previewControls || !["skip", "stop"].includes(name));
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
- const nowPlayingRows = () => (nowPlaying && !profileView && !importView && !firstRunView ? 1 : 0);
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
- const controls = ` ${position}/${count}${state ? ` · ${state}` : ""} · ${apple} · /skip · /stop`;
480
- const song = sanitizeTerminalText(`${track.title} · ${track.artist_credit}`).replace(/\s+/gu, " ").trim();
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
- if (event?.type === "queue") addPreviewNote(event);
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
- const acted = command === "stop" ? previewPlayer.stop() : previewPlayer.skip();
714
- if (!busy) setFooter(!acted ? tr("No previews are playing.") : command === "stop" ? tr("Previews stopped.") : tr("Skipped."));
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
- void firstRun.finish({ skipped: true }).catch(() => {});
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
- if (pending) {
2488
- if (messageQueue.length >= 30) editor.setText(pending);
2489
- else messageQueue.push(pending);
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
  }