@audiofool/moondog 0.3.0-beta.2 → 0.3.0-beta.4

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.
@@ -334,6 +334,10 @@ export async function runMoondogTui({
334
334
  // Between two of the stream's tracks nothing plays, and the line says the next one is being made.
335
335
  const dryGap = () => !nowPlaying && onAir?.phase === "waiting";
336
336
  const nowPlayingRows = () => ((nowPlaying || dryGap()) && !profileView && !importView && !firstRunView ? 1 : 0);
337
+ // Stable Audio 3's setup as the core shows it, here or from the window; a running download has its own line too.
338
+ let setupStatus = null;
339
+ const downloading = () => setupStatus?.job && !setupStatus.job.error ? setupStatus.job : null;
340
+ const downloadRows = () => (downloading() && !profileView && !importView && !firstRunView ? 1 : 0);
337
341
  // When the stream ends at the latest, as a clock time: an upper bound, so never a countdown. The clock
338
342
  // follows the screen's language, as the words around it do.
339
343
  const onAirUntil = () => {
@@ -391,7 +395,7 @@ export async function runMoondogTui({
391
395
  };
392
396
  const sleeve = new RecordSleeve({ terminal, getTheme, environment, getState: () => ({
393
397
  focused: homeFocused, selected: homeSelected, motionEnabled,
394
- editorRows: (editor.rowCount || 3) + queuedLineCount() + nowPlayingRows(),
398
+ editorRows: (editor.rowCount || 3) + queuedLineCount() + nowPlayingRows() + downloadRows(),
395
399
  }), getNextLyric: () => {
396
400
  try {
397
401
  homeLyric = homeLyricSeeds ? lyrics.selectHome(homeLyricSeeds) : null;
@@ -457,7 +461,7 @@ export async function runMoondogTui({
457
461
  render(width) {
458
462
  const lines = conversation.render(width);
459
463
  const footerRows = terminal.rows >= 20 ? 2 : 1;
460
- const bodyRows = Math.max(1, terminal.rows - 2 - footerRows - queuedLineCount() - nowPlayingRows() - (editor.rowCount || 3));
464
+ const bodyRows = Math.max(1, terminal.rows - 2 - footerRows - queuedLineCount() - nowPlayingRows() - downloadRows() - (editor.rowCount || 3));
461
465
  while (lines.length < bodyRows) lines.push(paintBrandLine("", width, theme));
462
466
  return lines;
463
467
  },
@@ -517,6 +521,7 @@ export async function runMoondogTui({
517
521
  ? theme.accent(prefix) + theme.text(truncateToWidth(song, room, "…")) + theme.faint(controls)
518
522
  : theme.accent(prefix) + theme.text(truncateToWidth(song, Math.max(1, width - visibleWidth(prefix)), "…")), width, theme));
519
523
  }
524
+ if (downloadRows()) lines.push(paintBrandLine(downloadLine(width), width, theme));
520
525
  if (messageQueue.length) {
521
526
  const hidden = Math.max(0, messageQueue.length - 4);
522
527
  const shown = messageQueue.slice(-4);
@@ -617,6 +622,7 @@ export async function runMoondogTui({
617
622
  new CombinedAutocompleteProvider(withCommandCompletions(localizedCommands, {
618
623
  // Service keys, such as Brave Search, have no models, so only /auth offers them.
619
624
  providers: () => [...providers(), ...serviceKeyProviders], models, authProviderIds: supportedAuthProviderIds, tr,
625
+ generationSetup: typeof generation?.setup?.install === "function",
620
626
  }), process.cwd(), null),
621
627
  );
622
628
  tui.setFocus(editor);
@@ -783,13 +789,124 @@ export async function runMoondogTui({
783
789
 
784
790
  // Making music on this Mac. /generation says where it stands; /generation on [folder] turns it on,
785
791
  // with the folder of Stability AI's MLX build when it is not the usual one; /generation off turns it off.
792
+ // Where the core can set Stable Audio 3 up itself (generation.setup), /generation download, use, remove
793
+ // and cancel manage the models Moondog keeps in its own folder. The window runs the same job, so a
794
+ // download started there shows here too.
786
795
  const home = homedir();
787
796
  const shownFolder = (folder) => sanitizeTerminalText(String(folder ?? "")).replace(home, "~");
797
+ const setup = typeof generation?.setup?.install === "function" ? generation.setup : null;
798
+ // The two models offered, by the word the listener types; sizes are the weights in all, before the
799
+ // setup reports its own.
800
+ const sa3Models = {
801
+ medium: { name: "Medium", choice: "medium", detail: N_("best sound"), total: 5_179_055_990 },
802
+ "small-music": { name: "Small Music", choice: "small", detail: N_("faster, lighter"), total: 1_704_727_702 },
803
+ };
804
+ const modelArgument = (value) => value === "medium" ? "medium" : ["small", "small-music"].includes(value) ? "small-music" : null;
805
+ const modelName = (model) => sa3Models[model]?.name ?? "Medium";
806
+ const gigabytes = (bytes) => {
807
+ const value = Math.max(0, Number(bytes) || 0) / 1e9;
808
+ try { return new Intl.NumberFormat(intlLocale(tr.locale), { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(value); }
809
+ catch { return value.toFixed(1); }
810
+ };
811
+ const modelSize = (model, status = setupStatus) => gigabytes(status?.models?.[model]?.total || sa3Models[model]?.total);
812
+ const setupSteps = { uv: N_("getting uv"), code: N_("getting Stable Audio 3's code"), python: N_("setting up Python"),
813
+ weights: N_("downloading the model"), verifying: N_("checking the files") };
814
+ const jobPercent = (job) => job?.total > 0 ? Math.min(100, Math.floor(((job.received ?? 0) / job.total) * 100)) : null;
815
+ const downloadLine = (width) => {
816
+ const job = downloading();
817
+ const prefix = ` ${theme.plain ? "v" : "↓"} `;
818
+ const percent = jobPercent(job);
819
+ const step = setupSteps[job.step] ? tr.marked(setupSteps[job.step]) : tr("getting ready");
820
+ const text = `Stable Audio 3 ${modelName(job.model)} · ${step}${percent === null ? "" : ` ${percent}%`}`;
821
+ const controls = " · /generation cancel";
822
+ const room = width - visibleWidth(prefix) - visibleWidth(controls);
823
+ return room >= 12 ? theme.accent(prefix) + theme.text(truncateToWidth(text, room, "…")) + theme.faint(controls)
824
+ : theme.accent(prefix) + theme.text(truncateToWidth(text, Math.max(1, width - visibleWidth(prefix)), "…"));
825
+ };
826
+ // One short sentence for a download that stopped on an error, by the setup's code, with the way to try again.
827
+ const setupErrorText = (error) => {
828
+ const code = typeof error === "string" ? error : error?.code;
829
+ if (code === "setup_disk_full") return tr("There isn't enough free space on this Mac for the download. Free some up, then use /generation download again.");
830
+ if (code === "setup_verification_failed") return tr("A downloaded file didn't match the one Stability AI publishes, so Moondog deleted it. Use /generation download to try again.");
831
+ if (code === "setup_offline" || code === "setup_download_failed") return tr("The download couldn't reach its source. Check your connection, then use /generation download to try again.");
832
+ if (code === "setup_python_failed") return tr("Stable Audio 3's Python environment couldn't be set up. Check your connection, then use /generation download to try again.");
833
+ if (code === "setup_busy") return tr("Another Moondog window or terminal is already downloading. /generation shows how far it is.");
834
+ const detail = sanitizeTerminalText(String(typeof error === "string" ? error : error?.message ?? code ?? "")).trim();
835
+ return tr("The download didn't finish: {error}. Use /generation download to try again.", { error: detail || tr("unknown error") });
836
+ };
837
+ // The model this room asked for. The core's setup chooses what makes music once it finishes; the
838
+ // terminal reads that choice and says how to switch when it kept another one.
839
+ let setupStarted = null;
840
+ const setupFinished = async (model) => {
841
+ setupStarted = null;
842
+ const status = await generation.check().catch(() => null);
843
+ if (cleanedUp) return;
844
+ const choice = sa3Models[model]?.choice ?? "medium";
845
+ setFooter(status?.state === "ready" && status.managed && status.model === model
846
+ ? tr("Stable Audio 3 {model} is ready. Ask for a track.", { model: modelName(model) })
847
+ : tr("Stable Audio 3 {model} is downloaded. Use it with /generation use {choice}.", { model: modelName(model), choice }), success);
848
+ };
849
+ const stopWatchingSetup = setup?.subscribe?.((status) => {
850
+ if (cleanedUp) return;
851
+ // The first snapshot is where things stand, not a change: an old failure waits for /generation.
852
+ const first = setupStatus === null;
853
+ const before = setupStatus?.job ?? null;
854
+ setupStatus = status ?? null;
855
+ const job = setupStatus?.job ?? null;
856
+ if (first) { tui.requestRender(); return; }
857
+ if (job?.error && !before?.error) setFooter(setupErrorText(job.error), failure);
858
+ else if (before && !before.error && !job) {
859
+ if (setupStatus?.models?.[before.model]?.state === "ready") void setupFinished(before.model);
860
+ else {
861
+ if (setupStarted === before.model) setupStarted = null;
862
+ setFooter(tr("Download stopped. /generation download {choice} picks it up where it left off.", { choice: sa3Models[before.model]?.choice ?? "medium" }));
863
+ }
864
+ }
865
+ tui.requestRender();
866
+ }) ?? null;
867
+ if (setup) {
868
+ Promise.resolve().then(() => setup.status()).then((status) => {
869
+ if (cleanedUp || setupStatus) return;
870
+ setupStatus = status ?? null;
871
+ tui.requestRender();
872
+ }, () => {});
873
+ }
874
+ // A fresh read for a command. The line above the draft follows subscribe alone, so a job that ends
875
+ // while a command reads still counts as finished.
876
+ const readSetup = async () => await setup.status();
877
+ // What each model is: its size, whether it's here, in use, or on its way.
878
+ const setupSummary = (status, generationStatus) => {
879
+ const lines = Object.entries(sa3Models).map(([model, { name, detail }]) => {
880
+ const entry = status?.models?.[model] ?? { state: "absent" };
881
+ const job = status?.job?.model === model ? status.job : null;
882
+ const percent = jobPercent(job);
883
+ const state = job?.error ? tr("download stopped on an error")
884
+ : job ? percent === null ? tr("downloading") : tr("downloading, {percent}%", { percent })
885
+ : entry.state === "ready"
886
+ ? generationStatus?.managed && generationStatus.model === model ? tr("installed, in use") : tr("installed")
887
+ : entry.state === "partial" ? tr("partly downloaded") : tr("not downloaded");
888
+ return `- Stable Audio 3 ${name} · ${tr.marked(detail)} · ${tr("{size} GB", { size: modelSize(model, status) })} · ${state}`;
889
+ });
890
+ const notes = [];
891
+ if (status?.job?.error) notes.push(setupErrorText(status.job.error));
892
+ if (generationStatus && !generationStatus.managed && generationStatus.install === "ready") {
893
+ notes.push(tr("In use: your own install at {folder}.", { folder: shownFolder(generationStatus.path) }));
894
+ }
895
+ notes.push(tr("Get a model with /generation download medium or small. /generation use, remove and cancel manage them."));
896
+ return [lines.join("\n"), ...notes].join("\n\n");
897
+ };
788
898
  const generationMessage = (status) => {
789
899
  const folder = shownFolder(status?.path);
790
900
  if (status?.install === "unsupported") return tr("Stable Audio 3 needs a Mac with Apple silicon, so Moondog can't make music here.");
791
901
  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.");
902
+ // Moondog's own install, or nothing anywhere yet: a download sets it up.
903
+ if (setup && status?.install !== "ready" && (status?.managed || (status?.source === "default" && status?.install === "missing"))) {
904
+ if (status?.state === "off") return tr("Making music on this Mac is off. Get Stable Audio 3 with /generation download medium, or small for a lighter model; music making turns on when it's done.");
905
+ const model = status?.model in sa3Models ? status.model : "medium";
906
+ return tr("Stable Audio 3 {model} isn't downloaded yet. Get it with /generation download {choice}.", { model: modelName(model), choice: sa3Models[model].choice });
907
+ }
792
908
  if (status?.state === "off") {
909
+ if (status.install === "ready" && status.managed) return tr("Making music on this Mac is off. Stable Audio 3 {model} is ready; turn it on with /generation on.", { model: modelName(status.model) });
793
910
  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
911
  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
912
  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 });
@@ -801,16 +918,121 @@ export async function runMoondogTui({
801
918
  }
802
919
  return tr("Stable Audio 3 isn't at {folder}. Use /generation on with the folder that holds its sa3 script.", { folder });
803
920
  };
921
+ const generationFooter = (status) => setFooter(status.state === "ready" ? tr("Music making on.") : status.state === "off" ? tr("Music making off.") : tr("Music making isn't ready."),
922
+ status.state === "ready" || status.state === "off" ? dim : warning);
923
+ const setupUsage = () => tr("Use /generation download, use or remove with medium or small, or /generation cancel.");
924
+ // /generation download, use, remove and cancel. A first download asks for the licenses with --agree;
925
+ // a removal asks once with --yes. The download runs in the background, on its own line above the draft.
926
+ const manageSetup = async (action, rest) => {
927
+ const flags = rest.filter((value) => value.startsWith("--"));
928
+ const values = rest.filter((value) => !value.startsWith("--"));
929
+ const allowed = action === "download" ? ["--agree"] : action === "remove" ? ["--yes"] : [];
930
+ const model = modelArgument(values[0]);
931
+ if ((action === "cancel" ? values.length > 0 : values.length !== 1 || !model) || flags.some((flag) => !allowed.includes(flag))) {
932
+ throw new Error(setupUsage());
933
+ }
934
+ const status = await readSetup();
935
+ if (status?.supported === false) {
936
+ addMoondogMessage(tr("Stable Audio 3 needs a Mac with Apple silicon, so Moondog can't make music here."));
937
+ setFooter(tr("Music making isn't ready."), warning);
938
+ return;
939
+ }
940
+ const running = status?.job && !status.job.error ? status.job : null;
941
+ if (action === "cancel") {
942
+ if (!running) { setFooter(tr("No download is running.")); return; }
943
+ await setup.cancel();
944
+ setFooter(tr("Download stopped. /generation download {choice} picks it up where it left off.", { choice: sa3Models[running.model]?.choice ?? "medium" }));
945
+ return;
946
+ }
947
+ const name = modelName(model);
948
+ const { choice } = sa3Models[model];
949
+ const entry = status?.models?.[model] ?? { state: "absent" };
950
+ if (action === "download") {
951
+ if (running) {
952
+ setFooter(running.model === model ? tr("Stable Audio 3 {model} is already downloading.", { model: name })
953
+ : tr("Another download is running. /generation cancel stops it."), running.model === model ? dim : warning);
954
+ return;
955
+ }
956
+ if (entry.state === "ready") {
957
+ addMoondogMessage(tr("Stable Audio 3 {model} is already on this Mac. Use it with /generation use {choice}.", { model: name, choice }));
958
+ setFooter(tr("Already downloaded."));
959
+ return;
960
+ }
961
+ const accepted = flags.includes("--agree");
962
+ if (!status?.licenses?.accepted && !accepted) {
963
+ addMoondogMessage(tr("Stable Audio 3 comes under the Stability AI Community License ({license}), and its text encoder, T5Gemma, under the Gemma Terms of Use ({gemma}). Moondog downloads it only from Stability AI's official sources and checks every file.\n\nTo agree and download {model} ({size} GB): /generation download {choice} --agree", {
964
+ license: status?.licenses?.links?.find(({ id }) => id === "stability-ai-community")?.url ?? "https://stability.ai/license",
965
+ gemma: status?.licenses?.links?.find(({ id }) => id === "gemma-terms")?.url ?? "https://huggingface.co/stabilityai/stable-audio-3-optimized/blob/main/LICENSE_GEMMA.md",
966
+ model: name, size: modelSize(model, status), choice,
967
+ }));
968
+ setFooter(tr("Waiting for your agreement."));
969
+ return;
970
+ }
971
+ setupStarted = model;
972
+ Promise.resolve().then(() => setup.install(model, { accepted })).catch((error) => {
973
+ if (setupStarted === model) setupStarted = null;
974
+ // A cancel ends the job too; the setup's own snapshot says so.
975
+ if (cleanedUp || error?.code === "setup_cancelled") return;
976
+ addMoondogMessage(setupErrorText(error));
977
+ setFooter(tr("The download didn't finish."), failure);
978
+ });
979
+ addMoondogMessage(tr("Downloading Stable Audio 3 {model} ({size} GB) from Stability AI's official sources. It runs in the background; /generation cancel stops it, and a later download picks up where it left off.", {
980
+ model: name, size: modelSize(model, status),
981
+ }));
982
+ setFooter(tr("Downloading Stable Audio 3 {model}.", { model: name }));
983
+ return;
984
+ }
985
+ if (action === "use") {
986
+ if (entry.state !== "ready") {
987
+ addMoondogMessage(tr("Stable Audio 3 {model} isn't downloaded yet. Get it with /generation download {choice}.", { model: name, choice }));
988
+ setFooter(tr("Music making isn't ready."), warning);
989
+ return;
990
+ }
991
+ const result = await generation.save({ enabled: true, source: "managed", model });
992
+ addMoondogMessage(generationMessage(result));
993
+ generationFooter(result);
994
+ return;
995
+ }
996
+ // remove
997
+ if (running) {
998
+ addMoondogMessage(tr("Wait for the download to finish, or stop it with /generation cancel, before removing a model."));
999
+ setFooter(tr("A download is running."), warning);
1000
+ return;
1001
+ }
1002
+ if (entry.state === "absent") {
1003
+ setFooter(tr("Stable Audio 3 {model} isn't on this Mac.", { model: name }));
1004
+ return;
1005
+ }
1006
+ if (!flags.includes("--yes")) {
1007
+ const last = Object.keys(sa3Models).every((other) => other === model || (status?.models?.[other]?.state ?? "absent") === "absent");
1008
+ const size = gigabytes(entry.bytes || entry.total || sa3Models[model].total);
1009
+ addMoondogMessage(last
1010
+ ? tr("Removing Stable Audio 3 {model} deletes its {size} GB from this Mac. It's the last model, so the rest of Moondog's Stable Audio 3 setup goes too. To go ahead: /generation remove {choice} --yes", { model: name, size, choice })
1011
+ : tr("Removing Stable Audio 3 {model} deletes its {size} GB from this Mac. To go ahead: /generation remove {choice} --yes", { model: name, size, choice }));
1012
+ setFooter(tr("Nothing removed yet."));
1013
+ return;
1014
+ }
1015
+ await setup.remove(model);
1016
+ await readSetup().catch(() => null);
1017
+ const after = await generation.check().catch(() => null);
1018
+ addMoondogMessage(tr("Removed Stable Audio 3 {model} from this Mac.", { model: name }));
1019
+ setFooter(after?.state === "ready" ? tr("Removed.") : tr("Removed. Music making isn't ready."), after?.state === "ready" ? success : warning);
1020
+ };
804
1021
  const manageGeneration = async (args) => {
805
1022
  const [action, ...rest] = args;
1023
+ if (setup && ["download", "use", "remove", "cancel"].includes(action)) {
1024
+ await manageSetup(action, rest);
1025
+ return;
1026
+ }
806
1027
  if ((action && !["on", "off"].includes(action)) || (action === "off" && rest.length)) {
807
- throw new Error(tr("Use /generation, /generation on [folder], or /generation off."));
1028
+ throw new Error(setup ? tr("Use /generation, /generation on [folder] or off, or /generation download, use or remove with medium or small.")
1029
+ : tr("Use /generation, /generation on [folder], or /generation off."));
808
1030
  }
809
1031
  const status = action ? await generation.save({ enabled: action === "on", ...(rest.length ? { path: rest.join(" ") } : {}) })
810
1032
  : 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);
1033
+ const summary = setup && !action && status?.install !== "unsupported" ? await readSetup().then((current) => setupSummary(current, status), () => null) : null;
1034
+ addMoondogMessage(summary ? `${generationMessage(status)}\n\n${summary}` : generationMessage(status));
1035
+ generationFooter(status);
814
1036
  };
815
1037
 
816
1038
  const removeLifecycleListeners = () => {
@@ -831,6 +1053,7 @@ export async function runMoondogTui({
831
1053
  stopWatchingFirstRun?.();
832
1054
  stopWatchingPreviews?.();
833
1055
  stopWatchingStream?.();
1056
+ stopWatchingSetup?.();
834
1057
  clearInterval(indicator);
835
1058
  indicator = null;
836
1059
  clearInterval(homeMotion);