@audiofool/moondog 0.2.2 → 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/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/surfaces/cli/command-completions.mjs +1 -0
- package/src/surfaces/cli/format-output.mjs +1 -0
- package/src/surfaces/cli/tui.mjs +70 -8
|
@@ -4,6 +4,8 @@ import { readFileSync } from "node:fs";
|
|
|
4
4
|
import { LOCALES, messageLocale, translator } from "../../i18n/index.mjs";
|
|
5
5
|
import { PROFILE_SECTIONS, TRAJECTORIES } from "../../profile/profile-exploration.mjs";
|
|
6
6
|
import { compactListenerProfile } from "../../profile/listener-profile-build.mjs";
|
|
7
|
+
import { BPM_RANGE, DESCRIPTOR_FACETS, DESCRIPTOR_LIMITS, DESCRIPTOR_SOURCES } from "../../profile/taste-compiler.mjs";
|
|
8
|
+
import { GENERATION_SECONDS } from "../../integrations/stable-audio/local-generator.mjs";
|
|
7
9
|
import { avoidKey } from "../../core/listener-avoids.mjs";
|
|
8
10
|
import { foldedWords, isCjkText, textNames, textNamesArtist } from "../../core/track-title.mjs";
|
|
9
11
|
|
|
@@ -177,7 +179,9 @@ function agentCapabilityAllowed(descriptor) {
|
|
|
177
179
|
spotifyCapabilityEffects.has(descriptor.effect)) ||
|
|
178
180
|
(descriptor.capability_id.startsWith("netease.") &&
|
|
179
181
|
spotifyCapabilityEffects.has(descriptor.effect)) ||
|
|
180
|
-
(descriptor.capability_id === "music.preview.play" && descriptor.effect === "play_preview")
|
|
182
|
+
(descriptor.capability_id === "music.preview.play" && descriptor.effect === "play_preview") ||
|
|
183
|
+
(["music.generate.local", "music.generate.replay", "music.generate.stream", "music.generate.stream.stop"].includes(descriptor.capability_id) &&
|
|
184
|
+
descriptor.effect === "generate_local")
|
|
181
185
|
);
|
|
182
186
|
}
|
|
183
187
|
|
|
@@ -3181,6 +3185,52 @@ function renderMix(value, t) {
|
|
|
3181
3185
|
return value?.mix ? t("queue.mix", value.mix) : null;
|
|
3182
3186
|
}
|
|
3183
3187
|
|
|
3188
|
+
// What the model learns of a track Moondog made: what shaped it and what was left out, and never
|
|
3189
|
+
// where the file is.
|
|
3190
|
+
function projectGeneratedTrack(value) {
|
|
3191
|
+
if (!isPlainObject(value) || !["playing", "not_made"].includes(value.state)) throw new Error("domain_result_invalid:generated_track");
|
|
3192
|
+
const leftOut = (Array.isArray(value.left_out ?? value.dropped) ? value.left_out ?? value.dropped : []).slice(0, 24).map((entry) => ({
|
|
3193
|
+
term: cleanOutputText(entry?.term || "-", 64, "generated_left_out_term"),
|
|
3194
|
+
reason: cleanOutputText(entry?.reason, 32, "generated_left_out_reason"),
|
|
3195
|
+
}));
|
|
3196
|
+
if (value.state === "not_made") return { state: "not_made", reason: cleanOutputText(value.reason, 64, "generated_reason"), left_out: leftOut };
|
|
3197
|
+
const source = (entry) => ({
|
|
3198
|
+
kind: cleanOutputText(entry?.kind, 16, "generated_source_kind"),
|
|
3199
|
+
...(entry?.claim_id ? { claim_id: cleanOutputText(entry.claim_id, 64, "generated_claim") } : {}),
|
|
3200
|
+
...(entry?.label ? { from: cleanOutputText(entry.label, 256, "generated_from") } : {}),
|
|
3201
|
+
...(entry?.artist ? { artist: cleanOutputText(entry.artist, 256, "generated_artist") } : {}),
|
|
3202
|
+
...(entry?.part_of_day ? { part_of_day: cleanOutputText(entry.part_of_day, 32, "generated_part_of_day") } : {}),
|
|
3203
|
+
});
|
|
3204
|
+
return {
|
|
3205
|
+
state: "playing",
|
|
3206
|
+
made_with: "Stable Audio 3 Medium, on this Mac",
|
|
3207
|
+
track: {
|
|
3208
|
+
id: cleanOutputText(value.track?.id, 64, "generated_id"),
|
|
3209
|
+
title: cleanOutputText(value.track?.title, 128, "generated_title"),
|
|
3210
|
+
seconds: safeNonnegativeInteger(Math.round(value.track?.seconds ?? 0), "generated_seconds"),
|
|
3211
|
+
seed: safeNonnegativeInteger(value.track?.seed ?? 0, "generated_seed"),
|
|
3212
|
+
},
|
|
3213
|
+
prompt: cleanOutputText(value.prompt, 1_024, "generated_prompt"),
|
|
3214
|
+
shaped_by: (Array.isArray(value.shaped_by) ? value.shaped_by : []).slice(0, DESCRIPTOR_LIMITS.total).map((entry) => ({
|
|
3215
|
+
term: cleanOutputText(entry?.term, 64, "generated_term"), facet: cleanOutputText(entry?.facet, 16, "generated_facet"), source: source(entry?.source),
|
|
3216
|
+
})),
|
|
3217
|
+
left_out: leftOut,
|
|
3218
|
+
...(Number.isInteger(value.bpm) ? { bpm: value.bpm } : {}),
|
|
3219
|
+
seconds_to_make: Math.round((value.elapsed_ms ?? 0) / 100) / 10,
|
|
3220
|
+
};
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
const trackLength = (seconds) => `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
|
|
3224
|
+
|
|
3225
|
+
function renderGeneratedTrack(value, t) {
|
|
3226
|
+
if (value?.state === "streaming" && value.track) {
|
|
3227
|
+
return [t("generate.made", { title: value.track.title, length: trackLength(value.track.seconds), seed: value.track.seed }),
|
|
3228
|
+
t("generate.stream", { minutes: value.minutes })].join("\n");
|
|
3229
|
+
}
|
|
3230
|
+
return value?.state === "playing" && value.track
|
|
3231
|
+
? t("generate.made", { title: value.track.title, length: trackLength(value.track.seconds), seed: value.track.seed }) : null;
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3184
3234
|
function projectSpotifyDiscovery(value) {
|
|
3185
3235
|
if (!isPlainObject(value) || value.provider !== "spotify" || !["verified_candidates", "unavailable", "no_matches"].includes(value.state)) throw new Error("domain_result_invalid:spotify_discovery");
|
|
3186
3236
|
const items = (value.items ?? []).slice(0, 36).map(item => {
|
|
@@ -4773,6 +4823,9 @@ function createToolFactories(
|
|
|
4773
4823
|
onSpotifyPlaylistEditWrite,
|
|
4774
4824
|
onExternalCandidateSet,
|
|
4775
4825
|
onMusicPlayback,
|
|
4826
|
+
onGeneratedTrack,
|
|
4827
|
+
onMadeTrackPlayed,
|
|
4828
|
+
generationContext,
|
|
4776
4829
|
onMusicCatalogArtistReleases,
|
|
4777
4830
|
onWebResearch,
|
|
4778
4831
|
onSpotifyQueuePlan,
|
|
@@ -5134,6 +5187,146 @@ function createToolFactories(
|
|
|
5134
5187
|
};
|
|
5135
5188
|
},
|
|
5136
5189
|
],
|
|
5190
|
+
[
|
|
5191
|
+
"music.generate.local",
|
|
5192
|
+
(descriptor) => {
|
|
5193
|
+
const literals = (values) => Type.Union(values.map((value) => Type.Literal(value)));
|
|
5194
|
+
const descriptorSchema = Type.Object(
|
|
5195
|
+
{
|
|
5196
|
+
term: Type.String({ minLength: 1, maxLength: DESCRIPTOR_LIMITS.characters }),
|
|
5197
|
+
facet: literals(DESCRIPTOR_FACETS),
|
|
5198
|
+
source: literals(DESCRIPTOR_SOURCES),
|
|
5199
|
+
from: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
5200
|
+
},
|
|
5201
|
+
{ additionalProperties: false },
|
|
5202
|
+
);
|
|
5203
|
+
return {
|
|
5204
|
+
name: descriptor.tool_name,
|
|
5205
|
+
label: descriptor.label,
|
|
5206
|
+
description:
|
|
5207
|
+
"Make one instrumental track on this Mac with the listener's own Stable Audio 3, and play it at once in Moondog's player, whatever the music service. Use it only when the listener asks Moondog to make, compose or generate music; never to play existing songs. It needs no web research, search, candidate set or playlist plan: read the profile if it helps, then call this tool. Translate their taste into plain sound descriptors and give each its source: reading (from = a claim_id of the saved reading; none without one), choice (from = an artist or song they marked Like), recent (from = an artist in recent_attention.stored_plays, the last four weeks; not the profile's longer recent lists or Spotify's own top artists), moment (the listener_clock), or request (their words in this message). A term is one idea in a few plain words for a genre, instrument, texture, mood or rhythm, such as dream pop, rhodes piano, tape saturation, unhurried, brushed drums. Say what is in the sound, never what is absent (no drums): the model can't leave a named sound out, so naming it brings it in. moment is the listener's local day and hour as listener_clock gives them; a time the listener names, such as tonight, is request. Never put an artist or song name in a term or the title. The host leaves out any term that names one, cannot be traced, comes from something they Avoid, or asks for singing (the model makes instrumentals only); left_out says which and why. A track needs at least one genre, instrument, texture or mood. If state is not_made, fix the descriptors once from left_out and reason, or tell the listener what was missing. bpm is optional. seconds is 15 to 380, 120 when left out. title is Moondog's own name for the record, a few plain words. Making a track takes from a few seconds to about a minute; make at most one per message. Making one ends Moondog's stream if one runs. product.made_tracks lists Moondog's newest tracks with their sound and outcomes: lean away from the sound of tracks the listener skipped and toward what they played through or asked for again by name (asked_for_again). replayed_by_moondog counts Moondog's own replays and says nothing about the listener. A track with stream: true played in the background: its completions say little, but its skips still count. These are outcomes of Moondog's own picks, not the listener's taste; never call them likes. Once it plays, say in one or two sentences what shaped it in everyday words (their reading, a Like, what they play lately, the hour). Describe only what the result's prompt and shaped_by hold: the model takes no key, chord or melody, so never name one. Do not recite the descriptors, the prompt or the seed; the host writes its own line under the reply.",
|
|
5208
|
+
parameters: Type.Object(
|
|
5209
|
+
{
|
|
5210
|
+
descriptors: Type.Array(descriptorSchema, { minItems: 1, maxItems: DESCRIPTOR_LIMITS.total }),
|
|
5211
|
+
bpm: Type.Optional(Type.Integer({ minimum: BPM_RANGE.min, maximum: BPM_RANGE.max })),
|
|
5212
|
+
bpm_source: Type.Optional(literals(["request", "moment", "reading"])),
|
|
5213
|
+
seconds: Type.Optional(Type.Integer({ minimum: GENERATION_SECONDS.min, maximum: GENERATION_SECONDS.max })),
|
|
5214
|
+
title: Type.Optional(Type.String({ minLength: 1, maxLength: 60 })),
|
|
5215
|
+
},
|
|
5216
|
+
{ additionalProperties: false },
|
|
5217
|
+
),
|
|
5218
|
+
executionMode: "sequential",
|
|
5219
|
+
execute: executeDomain(
|
|
5220
|
+
async (_toolCallId, parameters, signal, onUpdate) => application.makeTrack({
|
|
5221
|
+
descriptors: parameters.descriptors, bpm: parameters.bpm, bpmSource: parameters.bpm_source,
|
|
5222
|
+
seconds: parameters.seconds ?? GENERATION_SECONDS.default, title: parameters.title,
|
|
5223
|
+
}, {
|
|
5224
|
+
// The moment and the listener's words come from the host, never from the model.
|
|
5225
|
+
...(generationContext?.() ?? {}),
|
|
5226
|
+
signal,
|
|
5227
|
+
onProgress: ({ stage }) => onUpdate?.({ content: [{ type: "text", text: stage }], details: { stage } }),
|
|
5228
|
+
}),
|
|
5229
|
+
projectGeneratedTrack,
|
|
5230
|
+
onGeneratedTrack,
|
|
5231
|
+
),
|
|
5232
|
+
};
|
|
5233
|
+
},
|
|
5234
|
+
],
|
|
5235
|
+
[
|
|
5236
|
+
"music.generate.stream",
|
|
5237
|
+
(descriptor) => {
|
|
5238
|
+
const literals = (values) => Type.Union(values.map((value) => Type.Literal(value)));
|
|
5239
|
+
const descriptorSchema = Type.Object(
|
|
5240
|
+
{
|
|
5241
|
+
term: Type.String({ minLength: 1, maxLength: DESCRIPTOR_LIMITS.characters }),
|
|
5242
|
+
facet: literals(DESCRIPTOR_FACETS),
|
|
5243
|
+
source: literals(DESCRIPTOR_SOURCES),
|
|
5244
|
+
from: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
5245
|
+
},
|
|
5246
|
+
{ additionalProperties: false },
|
|
5247
|
+
);
|
|
5248
|
+
return {
|
|
5249
|
+
name: descriptor.tool_name,
|
|
5250
|
+
label: descriptor.label,
|
|
5251
|
+
description:
|
|
5252
|
+
"Start Moondog's stream: instrumental tracks made on this Mac from the listener's taste, one after another, for background listening (work, reading, a long evening). The first plays at once; each next one is made while the last plays, and features a different few of the descriptors, so give a broad set: genres, plus several instruments, textures, moods and rhythms. Descriptors, sources and rules are the same as moondog_make_track. A track the listener skips within 30 seconds steers the stream away from what it featured. seconds is each track's length, 60 to 380, 150 when left out; minutes is how long the stream runs, 10 to 180, 60 when left out. Starting one replaces a stream already running, and making a single track ends it. Use it only when the listener asks for music to keep going in the background; for one track, use moondog_make_track. Say in one or two sentences what shapes it, and that they can stop it any time; the host writes its own line under the reply.",
|
|
5253
|
+
parameters: Type.Object(
|
|
5254
|
+
{
|
|
5255
|
+
descriptors: Type.Array(descriptorSchema, { minItems: 1, maxItems: DESCRIPTOR_LIMITS.total }),
|
|
5256
|
+
bpm: Type.Optional(Type.Integer({ minimum: BPM_RANGE.min, maximum: BPM_RANGE.max })),
|
|
5257
|
+
bpm_source: Type.Optional(literals(["request", "moment", "reading"])),
|
|
5258
|
+
seconds: Type.Optional(Type.Integer({ minimum: 60, maximum: GENERATION_SECONDS.max })),
|
|
5259
|
+
minutes: Type.Optional(Type.Integer({ minimum: 10, maximum: 180 })),
|
|
5260
|
+
title: Type.Optional(Type.String({ minLength: 1, maxLength: 60 })),
|
|
5261
|
+
},
|
|
5262
|
+
{ additionalProperties: false },
|
|
5263
|
+
),
|
|
5264
|
+
executionMode: "sequential",
|
|
5265
|
+
execute: executeDomain(
|
|
5266
|
+
async (_toolCallId, parameters, signal, onUpdate) => application.startStream({
|
|
5267
|
+
descriptors: parameters.descriptors, bpm: parameters.bpm, bpmSource: parameters.bpm_source,
|
|
5268
|
+
seconds: parameters.seconds ?? 150, minutes: parameters.minutes ?? 60, title: parameters.title,
|
|
5269
|
+
}, {
|
|
5270
|
+
...(generationContext?.() ?? {}),
|
|
5271
|
+
signal,
|
|
5272
|
+
onProgress: ({ stage }) => onUpdate?.({ content: [{ type: "text", text: stage }], details: { stage } }),
|
|
5273
|
+
}),
|
|
5274
|
+
(value) => {
|
|
5275
|
+
if (value?.state === "not_made") return projectGeneratedTrack(value);
|
|
5276
|
+
if (value?.state !== "streaming") throw new Error("domain_result_invalid:stream");
|
|
5277
|
+
return { ...projectGeneratedTrack({ ...value, state: "playing" }), state: "streaming", minutes: safeNonnegativeInteger(value.minutes ?? 0, "stream_minutes") };
|
|
5278
|
+
},
|
|
5279
|
+
onGeneratedTrack,
|
|
5280
|
+
),
|
|
5281
|
+
};
|
|
5282
|
+
},
|
|
5283
|
+
],
|
|
5284
|
+
[
|
|
5285
|
+
"music.generate.stream.stop",
|
|
5286
|
+
(descriptor) => ({
|
|
5287
|
+
name: descriptor.tool_name,
|
|
5288
|
+
label: descriptor.label,
|
|
5289
|
+
description: "Stop Moondog's stream of tracks made on this Mac when the listener asks to stop it. The track playing stops too.",
|
|
5290
|
+
parameters: emptyParameters,
|
|
5291
|
+
executionMode: "sequential",
|
|
5292
|
+
execute: executeDomain(
|
|
5293
|
+
async () => application.stopStream(),
|
|
5294
|
+
(value) => ({ state: value?.state === "stopped" ? "stopped" : "not_running",
|
|
5295
|
+
...(Number.isInteger(value?.tracks_made) ? { tracks_made: value.tracks_made } : {}) }),
|
|
5296
|
+
),
|
|
5297
|
+
}),
|
|
5298
|
+
],
|
|
5299
|
+
[
|
|
5300
|
+
"music.generate.replay",
|
|
5301
|
+
(descriptor) => ({
|
|
5302
|
+
name: descriptor.tool_name,
|
|
5303
|
+
label: descriptor.label,
|
|
5304
|
+
description:
|
|
5305
|
+
"Play again a track Moondog made on this Mac, by its id from product.made_tracks, in Moondog's own player. Use it when the listener asks to hear one of Moondog's tracks again; to make a new one, use moondog_make_track. A track with audio_kept: false has only its receipt left, so it can't play again. Playing one again ends Moondog's stream if one runs. chosen_by says whether the listener named it (their own pick) or Moondog chose it.",
|
|
5306
|
+
parameters: Type.Object(
|
|
5307
|
+
{ id: Type.String({ minLength: 36, maxLength: 36 }) },
|
|
5308
|
+
{ additionalProperties: false },
|
|
5309
|
+
),
|
|
5310
|
+
executionMode: "sequential",
|
|
5311
|
+
execute: executeDomain(
|
|
5312
|
+
async (_toolCallId, parameters, signal) => application.playMadeTrack({ id: parameters.id },
|
|
5313
|
+
{ request: generationContext?.({ replay: true })?.request ?? null, signal }),
|
|
5314
|
+
(value) => {
|
|
5315
|
+
if (!isPlainObject(value) || value.state !== "playing") throw new Error("domain_result_invalid:made_track");
|
|
5316
|
+
return {
|
|
5317
|
+
state: "playing",
|
|
5318
|
+
track: {
|
|
5319
|
+
id: cleanOutputText(value.track?.id, 64, "made_track_id"),
|
|
5320
|
+
title: cleanOutputText(value.track?.title, 128, "made_track_title"),
|
|
5321
|
+
...(Number.isFinite(value.track?.seconds) ? { seconds: Math.round(value.track.seconds) } : {}),
|
|
5322
|
+
},
|
|
5323
|
+
chosen_by: value.chosen_by === "listener" ? "listener" : "moondog",
|
|
5324
|
+
};
|
|
5325
|
+
},
|
|
5326
|
+
onMadeTrackPlayed,
|
|
5327
|
+
),
|
|
5328
|
+
}),
|
|
5329
|
+
],
|
|
5137
5330
|
[
|
|
5138
5331
|
"netease.search",
|
|
5139
5332
|
(descriptor) => ({
|
|
@@ -6426,8 +6619,11 @@ function toolForModel(tool) {
|
|
|
6426
6619
|
// Spotify can be connected for reading while the listener chose another service to play music.
|
|
6427
6620
|
const SERVICE_NAMES = { apple_music: "Apple Music", netease: "NetEase Music (网易云音乐)" };
|
|
6428
6621
|
|
|
6429
|
-
|
|
6430
|
-
|
|
6622
|
+
// Making a track plays in Moondog's own player under every music service.
|
|
6623
|
+
const GENERATION_LINE = "- moondog_make_track makes an instrumental track on this Mac from the listener's taste and plays it in Moondog's own player, whatever the music service. Use it only when they ask Moondog to make, compose or generate music, not to play songs that exist. moondog_play_made_track plays again one Moondog already made (product.made_tracks). When the stream tools are offered, moondog_start_stream keeps making tracks one after another for background listening, and moondog_stop_stream ends it.";
|
|
6624
|
+
|
|
6625
|
+
function sessionAvailability({ spotifyReady, spotifyPlaybackReady = spotifyReady, previewReady, playbackService = "apple_music", neteaseReady = false, localGenerationReady = false }) {
|
|
6626
|
+
if (spotifyPlaybackReady) return localGenerationReady ? `\n\nSession availability:\n${GENERATION_LINE}` : "";
|
|
6431
6627
|
const lines = [
|
|
6432
6628
|
"Session availability:",
|
|
6433
6629
|
spotifyReady
|
|
@@ -6455,9 +6651,12 @@ function sessionAvailability({ spotifyReady, spotifyPlaybackReady = spotifyReady
|
|
|
6455
6651
|
? "- Do not suggest switching to Spotify or linking another account to play music here."
|
|
6456
6652
|
: "- Do not suggest linking Spotify or another account to play music here.",
|
|
6457
6653
|
);
|
|
6654
|
+
} else if (localGenerationReady) {
|
|
6655
|
+
lines.push("- No music service can play or queue songs in this session. Say so once, briefly, when the listener asks for songs; moondog_make_track can still make them a track of Moondog's own.");
|
|
6458
6656
|
} else {
|
|
6459
6657
|
lines.push("- You cannot play or queue music in this session. Say so once, briefly, then help with what you can: the song, its story, and what to try next.");
|
|
6460
6658
|
}
|
|
6659
|
+
if (localGenerationReady) lines.push(GENERATION_LINE);
|
|
6461
6660
|
if (previewReady || neteaseReady) {
|
|
6462
6661
|
lines.push(
|
|
6463
6662
|
"- Playback verification belongs to the selected music service. Its play tool resolves and checks recordings. Use the returned songs and outcome to answer directly; do not run another catalog search or playlist planner just to recheck a successful playback result. A Spotify player or queue read cannot verify playback on another service.",
|
|
@@ -6478,6 +6677,7 @@ function sessionSection(application) {
|
|
|
6478
6677
|
previewReady: application.musicPreviewReady?.() ?? false,
|
|
6479
6678
|
playbackService: application.playbackService ?? "apple_music",
|
|
6480
6679
|
neteaseReady: application.neteaseReady?.() ?? false,
|
|
6680
|
+
localGenerationReady: application.localGenerationReady?.() ?? false,
|
|
6481
6681
|
}).trim() || null;
|
|
6482
6682
|
}
|
|
6483
6683
|
|
|
@@ -6490,6 +6690,7 @@ function playbackSurfaceKey(application) {
|
|
|
6490
6690
|
application.musicPreviewReady?.() ?? false,
|
|
6491
6691
|
application.neteaseReady?.() ?? false,
|
|
6492
6692
|
application.listeningReadable?.() ?? false,
|
|
6693
|
+
application.localGenerationReady?.() ?? false,
|
|
6493
6694
|
]);
|
|
6494
6695
|
}
|
|
6495
6696
|
|
|
@@ -6638,7 +6839,7 @@ Output rules:
|
|
|
6638
6839
|
- For a playlist with a stated size, return that many tracks. When the candidates cannot fill it with good fits, say so and ask before planning fewer. For a queue or an open pick, see Fewer, better.
|
|
6639
6840
|
- For a library or history plan, explain the order and give a concrete reason for every selected track. The host shows an external plan as the songs plus one source line.
|
|
6640
6841
|
- State uncertainty or ask to broaden the search when results are sparse.
|
|
6641
|
-
- Spotify playback controls, library saves, and private playlist writes may be used through the registered tools. Publishing, messaging, deletion, paid generation, and all other external effects remain disabled.
|
|
6842
|
+
- Spotify playback controls, library saves, and private playlist writes may be used through the registered tools. When the listener has turned it on, moondog_make_track makes music on this Mac, which nothing leaves. Publishing, messaging, deletion, paid or online generation, and all other external effects remain disabled.
|
|
6642
6843
|
|
|
6643
6844
|
Curation craft:
|
|
6644
6845
|
- Start from an anchor the listener already knows (a thread in the saved reading, an artist in recent attention, what is playing now) and move outward one step at a time: the same scene, era, producer, label, players or lineage. Name the anchor once.
|
|
@@ -7153,6 +7354,9 @@ function productContext(application, query, now, { runtime, source }) {
|
|
|
7153
7354
|
spotify_playback_context: application.spotifyPlaybackContextStatus?.() ?? null,
|
|
7154
7355
|
// A host that cannot know the listener's time zone passes no clock, and the time stays unknown.
|
|
7155
7356
|
listener_clock: now ? listenerClock(now) : null,
|
|
7357
|
+
// The tracks Moondog made lately and how each landed, once it can make them, and its stream while one runs.
|
|
7358
|
+
...(application.madeTracks?.() ? { made_tracks: application.madeTracks() } : {}),
|
|
7359
|
+
...(application.streamStatus?.() ? { stream: application.streamStatus() } : {}),
|
|
7156
7360
|
reply_language: replyLanguage(query, application),
|
|
7157
7361
|
};
|
|
7158
7362
|
}
|
|
@@ -7328,6 +7532,39 @@ export class PiAgentRuntime {
|
|
|
7328
7532
|
if (value.playing_in_order?.length) this.activePromptState.previewsPlaying = true;
|
|
7329
7533
|
}
|
|
7330
7534
|
},
|
|
7535
|
+
// A track Moondog made, played again: a Like said while it plays is about Moondog's own record.
|
|
7536
|
+
onMadeTrackPlayed: (value) => {
|
|
7537
|
+
if (typeof value?.track?.title === "string") this.generatedTitles.add(avoidKey(value.track.title));
|
|
7538
|
+
},
|
|
7539
|
+
onGeneratedTrack: (value) => {
|
|
7540
|
+
if (!["playing", "streaming"].includes(value?.state)) return;
|
|
7541
|
+
this.generatedTitles.add(avoidKey(value.track.title));
|
|
7542
|
+
this.activePromptState?.generatedTracks.push(structuredClone(value));
|
|
7543
|
+
},
|
|
7544
|
+
// A track is shaped by the moment and the listener's own words, which the host supplies and the
|
|
7545
|
+
// model never does. One message makes at most one track, with one more try if nothing was made.
|
|
7546
|
+
generationContext: ({ replay = false } = {}) => {
|
|
7547
|
+
const state = this.activePromptState;
|
|
7548
|
+
const refuse = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
7549
|
+
if (!state) refuse("generation_no_prompt", "A track can be made only while answering the listener.");
|
|
7550
|
+
// Playing a made track again needs only the listener's words, to tell their pick from Moondog's.
|
|
7551
|
+
if (replay) {
|
|
7552
|
+
if (state.replays >= 1) refuse("generation_turn_limit", "Nothing more was played. One message plays at most one of Moondog's tracks again.");
|
|
7553
|
+
state.replays += 1;
|
|
7554
|
+
return { request: state.promptText };
|
|
7555
|
+
}
|
|
7556
|
+
if (state.generatedTracks.length > 0 || state.generationAttempts >= 2) {
|
|
7557
|
+
refuse("generation_turn_limit", "Nothing more was made. One message makes at most one track; the listener can ask for another.");
|
|
7558
|
+
}
|
|
7559
|
+
state.generationAttempts += 1;
|
|
7560
|
+
const now = this.clock?.();
|
|
7561
|
+
return {
|
|
7562
|
+
moment: now instanceof Date && Number.isFinite(now.getTime()) ? listenerClock(now) : null,
|
|
7563
|
+
request: state.promptText,
|
|
7564
|
+
// A conversation that read Spotify keeps the listener's words out of the receipt, as out of memory.
|
|
7565
|
+
transient: this.transientSpotifyContext || this.application.transientSpotifyContext === true,
|
|
7566
|
+
};
|
|
7567
|
+
},
|
|
7331
7568
|
onExternalCandidateSet: (value, { playbackLookup = false } = {}) => {
|
|
7332
7569
|
if (this.activePromptState) {
|
|
7333
7570
|
this.activePromptState.externalCandidateSetCreated = true;
|
|
@@ -7408,6 +7645,17 @@ export class PiAgentRuntime {
|
|
|
7408
7645
|
}
|
|
7409
7646
|
const track = parameters.subject === "track";
|
|
7410
7647
|
if (track && !parameters.title) refuse("taste_choice_title_required", "Nothing was saved. A song choice needs its title and artist.");
|
|
7648
|
+
// Moondog's own tracks have no artist, and "Moondog" is also a real composer's name. A choice is
|
|
7649
|
+
// about a track Moondog made when it names one of their titles with Moondog as artist, puts such a
|
|
7650
|
+
// title in place of the artist, or points at "this" while it names no song. Anything else, the
|
|
7651
|
+
// real composer included, is saved as usual.
|
|
7652
|
+
// Titles made in this session, and the newest ones on this Mac, which a replay may be playing.
|
|
7653
|
+
const madeTitles = new Set([...this.generatedTitles, ...(application.madeTracks?.() ?? []).map((made) => avoidKey(made.title))]);
|
|
7654
|
+
const madeHere = (value) => madeTitles.has(avoidKey(value ?? ""));
|
|
7655
|
+
const moondog = avoidKey(parameters.artist ?? "") === "moondog";
|
|
7656
|
+
if (madeHere(parameters.artist) || (moondog && (track ? madeHere(parameters.title) : quoteIsDeictic(quote) && madeTitles.size > 0))) {
|
|
7657
|
+
refuse("taste_choice_generated_track", "Nothing was saved. This is a track Moondog made, and choices about Moondog's own tracks are not saved yet. Say so, and offer to make another one like it.");
|
|
7658
|
+
}
|
|
7411
7659
|
if (!quoteLongEnough(quote)) {
|
|
7412
7660
|
refuse("taste_choice_quote_too_short", "Nothing was saved. source_text must quote the listener's whole statement of the choice, not one word.");
|
|
7413
7661
|
}
|
|
@@ -7445,6 +7693,8 @@ export class PiAgentRuntime {
|
|
|
7445
7693
|
};
|
|
7446
7694
|
// Every tool name this agent has offered. One that later goes away left with a change of music service.
|
|
7447
7695
|
this.offeredTools = new Set();
|
|
7696
|
+
// The titles of the tracks this agent made, so a Like said while one plays is not saved as a real song's.
|
|
7697
|
+
this.generatedTitles = new Set();
|
|
7448
7698
|
const surface = this.#buildSurface();
|
|
7449
7699
|
this.surfaceKey = surface.key;
|
|
7450
7700
|
this.capabilityByToolName = surface.capabilities;
|
|
@@ -7666,6 +7916,9 @@ export class PiAgentRuntime {
|
|
|
7666
7916
|
presentationFailure: false,
|
|
7667
7917
|
stagedMemoryMutations: [],
|
|
7668
7918
|
tasteChoices: [],
|
|
7919
|
+
generatedTracks: [],
|
|
7920
|
+
generationAttempts: 0,
|
|
7921
|
+
replays: 0,
|
|
7669
7922
|
};
|
|
7670
7923
|
this.activePromptState = promptState;
|
|
7671
7924
|
const profileDiscoveryUnavailable = () => Boolean(callbacks.profileSeed) &&
|
|
@@ -7742,6 +7995,8 @@ export class PiAgentRuntime {
|
|
|
7742
7995
|
receipts.push(...unresolvedPlaybackFailures().map((failure) => renderSpotifyPlaybackFailure(failure, t)));
|
|
7743
7996
|
return receipts;
|
|
7744
7997
|
};
|
|
7998
|
+
// A track that was made stays made, so its line stays when the turn is cancelled or interrupted too.
|
|
7999
|
+
const generatedTexts = () => promptState.generatedTracks.map((track) => renderGeneratedTrack(track, t)).filter(Boolean);
|
|
7745
8000
|
// A choice is saved at once, so its line stays even when the turn is cancelled or interrupted.
|
|
7746
8001
|
const choiceTexts = () => promptState.tasteChoices.map((choice) => t(`choice.${choice.state === "retracted" ? "retract" : choice.stance}`, {
|
|
7747
8002
|
title: choice.entity_type === "track" ? choice.label : "",
|
|
@@ -7787,7 +8042,7 @@ export class PiAgentRuntime {
|
|
|
7787
8042
|
const queueMix = promptState.spotifyQueuePlan?.queued?.length ? renderMix(promptState.spotifyQueuePlan, t) : null;
|
|
7788
8043
|
const playbackMix = renderMix(promptState.musicPlaybackResult, t);
|
|
7789
8044
|
const leftOut = [promptState.spotifyQueuePlan, ...promptState.spotifyWriteReceipts].map((receipt) => renderSkippedAvoided(receipt, t));
|
|
7790
|
-
for (const mix of [queueMix, playbackMix, ...leftOut]) {
|
|
8045
|
+
for (const mix of [queueMix, playbackMix, ...leftOut, ...generatedTexts()]) {
|
|
7791
8046
|
if (!mix || resultText.includes(mix)) continue;
|
|
7792
8047
|
const addition = resultText ? `\n\n${mix}` : mix;
|
|
7793
8048
|
resultText += addition;
|
|
@@ -7889,6 +8144,8 @@ export class PiAgentRuntime {
|
|
|
7889
8144
|
if (ownEntryId && this.nameConversations) {
|
|
7890
8145
|
try { ownSessionId = this.application.ensureMemorySession?.()?.session_id ?? null; } catch { /* The conversation keeps its first message as its name. */ }
|
|
7891
8146
|
}
|
|
8147
|
+
// The tracks Moondog made, read again, so the other surface's tracks and outcomes are in this reply's context.
|
|
8148
|
+
try { await this.application.refreshMadeTracks?.(); } catch { /* The last read stands. */ }
|
|
7892
8149
|
this.application.beginPrompt({ text, recentTexts: recentListenerTexts(this.agent.state.messages.slice(0, historyStartIndex)) });
|
|
7893
8150
|
promptScopeStarted = true;
|
|
7894
8151
|
if (callbacks.profileSeed) this.application.setProfileDiscoverySeed(callbacks.profileSeed);
|
|
@@ -7949,6 +8206,18 @@ export class PiAgentRuntime {
|
|
|
7949
8206
|
});
|
|
7950
8207
|
}
|
|
7951
8208
|
|
|
8209
|
+
// A long tool, such as making a track, says which stage it is in.
|
|
8210
|
+
if (event.type === "tool_execution_update" && typeof event.partialResult?.details?.stage === "string") {
|
|
8211
|
+
const descriptor = this.capabilityByToolName.get(event.toolName);
|
|
8212
|
+
callbacks.onToolUpdate?.({
|
|
8213
|
+
toolCallId: event.toolCallId,
|
|
8214
|
+
toolName: event.toolName,
|
|
8215
|
+
capabilityId: descriptor?.capability_id ?? "unknown",
|
|
8216
|
+
label: descriptor?.label ?? event.toolName,
|
|
8217
|
+
stage: event.partialResult.details.stage,
|
|
8218
|
+
});
|
|
8219
|
+
}
|
|
8220
|
+
|
|
7952
8221
|
if (event.type === "tool_execution_end") {
|
|
7953
8222
|
const descriptor = this.capabilityByToolName.get(event.toolName);
|
|
7954
8223
|
const resultText = event.result?.content?.map((part) => part.text ?? "").join("") ?? "";
|
|
@@ -8022,7 +8291,7 @@ export class PiAgentRuntime {
|
|
|
8022
8291
|
this.application.cancelSpotifyQueueRequest?.();
|
|
8023
8292
|
discardPromptHistory(this.agent, historyStartIndex);
|
|
8024
8293
|
historyFinalized = true;
|
|
8025
|
-
const receipts = [...spotifyEffectTexts(), ...choiceTexts()];
|
|
8294
|
+
const receipts = [...spotifyEffectTexts(), ...generatedTexts(), ...choiceTexts()];
|
|
8026
8295
|
const abortedText = receipts.length > 0
|
|
8027
8296
|
? [t("turn.cancelled"), ...receipts].join("\n\n")
|
|
8028
8297
|
: promptState.toolExecutionStarted ? "" : streamedText || finalText;
|
|
@@ -8137,8 +8406,8 @@ To confirm, reply: ${preview.confirmation}`].join("\n\n");
|
|
|
8137
8406
|
// Discovered songs need a validated plan; a catalog lookup, such as whether a song is there, is reported in the model's words.
|
|
8138
8407
|
promptState.externalCandidateSets.some((set) => !set.playbackLookup) &&
|
|
8139
8408
|
!promptState.playlistPlanAttempted &&
|
|
8140
|
-
// A song the model played, queued or failed to play is reported in its own words.
|
|
8141
|
-
!promptState.musicPlaybackResult &&
|
|
8409
|
+
// A song the model played, queued or failed to play is reported in its own words, as is a track it made.
|
|
8410
|
+
!promptState.musicPlaybackResult && !promptState.generatedTracks.length &&
|
|
8142
8411
|
!promptState.spotifyPlayback.length && !promptState.spotifyWriteReceipts.length && !promptState.spotifyPlaybackFailures.length
|
|
8143
8412
|
) {
|
|
8144
8413
|
const safeFailureText = [renderUnvalidatedPlaylistPlan(t), ...spotifyEffectTexts()].join("\n\n");
|
|
@@ -8183,7 +8452,7 @@ To confirm, reply: ${preview.confirmation}`].join("\n\n");
|
|
|
8183
8452
|
// its receipt. Keep only host evidence; discard model claims and staged
|
|
8184
8453
|
// memory/plans. The caller can still display the returned receipt if its
|
|
8185
8454
|
// streaming callback itself was the source of the exception.
|
|
8186
|
-
const receipts = [...spotifyEffectTexts(), ...choiceTexts()];
|
|
8455
|
+
const receipts = [...spotifyEffectTexts(), ...generatedTexts(), ...choiceTexts()];
|
|
8187
8456
|
if (!receipts.length) throw error;
|
|
8188
8457
|
const interruptedText = [t("turn.interrupted"), ...receipts].join("\n\n");
|
|
8189
8458
|
compactCompletedPromptHistory(this.agent, historyStartIndex, interruptedText);
|
|
@@ -93,6 +93,7 @@ export function withCommandCompletions(commands, {
|
|
|
93
93
|
art: { argumentHint: "[braille|ascii|off|auto]", choices: firstArgument(artChoices) },
|
|
94
94
|
motion: { argumentHint: "[on|off]", choices: firstArgument(motionChoices) },
|
|
95
95
|
service: { argumentHint: "[spotify|apple_music|netease]", choices: firstArgument(serviceChoices) },
|
|
96
|
+
generation: { argumentHint: "[on [folder]|off]", choices: firstArgument(choices([["on", N_("Make music on this Mac with Stable Audio 3")], ["off", N_("Stop making music here")]])) },
|
|
96
97
|
lyrics: { argumentHint: "[sync]", choices: firstArgument(choices([["sync", N_("Check profile songs for lyrics")]])) },
|
|
97
98
|
web: { argumentHint: "search <query> | read <url> | status", choices: firstArgument(webChoices) },
|
|
98
99
|
spotify: {
|
|
@@ -472,6 +472,7 @@ function formatTools(value, tr) {
|
|
|
472
472
|
const doctorLabels = {
|
|
473
473
|
"web.search": N_("Web search"),
|
|
474
474
|
"web.read": N_("Web page reading"),
|
|
475
|
+
"music.generate.local": N_("Making music on this Mac"),
|
|
475
476
|
"node.version": "Node.js",
|
|
476
477
|
"apple_music.source": N_("Apple Music library"),
|
|
477
478
|
"profile.projection": N_("Listening profile"),
|
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
|
}
|