@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
|
@@ -14,6 +14,10 @@ export const SUGGESTION_WINDOW = Object.freeze({ beforeMs: 60_000, afterMs: 6 *
|
|
|
14
14
|
|
|
15
15
|
export const SUGGESTION_SERVICES = Object.freeze(["spotify", "netease", "apple_music"]);
|
|
16
16
|
|
|
17
|
+
// A track Moondog made on this Mac (src/integrations/stable-audio). It has no artist, so no song
|
|
18
|
+
// key forms, and only its own id can ever match it: never one of the listener's plays of a real song.
|
|
19
|
+
const GENERATED_TRACK = /^moondog:generated:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
20
|
+
|
|
17
21
|
// One artist's name can hold a separator ("AC/DC", "Simon & Garfunkel", "Tyler, The Creator"),
|
|
18
22
|
// so a bare credit is tried whole, then by its comma parts, then by every part.
|
|
19
23
|
function creditSplits(credit) {
|
|
@@ -72,6 +76,11 @@ export function suggestionIdentities(suggestion) {
|
|
|
72
76
|
* started names its release instead, so every song of it that plays matches.
|
|
73
77
|
*/
|
|
74
78
|
export function suggestionEntry({ service, action, providerTrackId = null, isrc = null, title, release = null, artists, artistCredit, mixSlot = null, requestedAt }) {
|
|
79
|
+
if (service === "moondog") {
|
|
80
|
+
if (!GENERATED_TRACK.test(providerTrackId ?? "") || typeof title !== "string" || !title.trim()) return null;
|
|
81
|
+
return { service, action, provider_track_id: providerTrackId, isrc: null, track_key: null, release_key: null,
|
|
82
|
+
title: Array.from(title.trim()).slice(0, 512).join(""), artist_credit: "", requested_at: requestedAt, mix_slot: null };
|
|
83
|
+
}
|
|
75
84
|
const credit = typeof artistCredit === "string" && artistCredit.trim() ? artistCredit.trim()
|
|
76
85
|
: Array.isArray(artists) ? artists.filter((name) => typeof name === "string").join(", ") : "";
|
|
77
86
|
const named = { title, release, artists, artist_credit: credit };
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// The taste compiler: what shapes a track Moondog makes, checked against the listener's evidence
|
|
2
|
+
// before any model hears it (docs/MUSIC_DECENTRALIZATION.md, "The profile as a taste compiler").
|
|
3
|
+
// The agent translates taste into sound descriptors; the host keeps only those it can trace to a
|
|
4
|
+
// saved reading, a Like, recent attention, the moment or the listener's own request, and none that
|
|
5
|
+
// names an artist or a song. The descriptors depend on no model; an adapter per model writes the prompt.
|
|
6
|
+
import { ARTIST_CREDIT_SEPARATORS, avoidKey } from "../core/listener-avoids.mjs";
|
|
7
|
+
import { normalizedTrackTitle, textNames, textNamesArtist } from "../core/track-title.mjs";
|
|
8
|
+
|
|
9
|
+
export const DESCRIPTOR_FACETS = Object.freeze(["genre", "instrument", "texture", "mood", "rhythm"]);
|
|
10
|
+
export const DESCRIPTOR_SOURCES = Object.freeze(["reading", "choice", "recent", "moment", "request"]);
|
|
11
|
+
export const DESCRIPTOR_LIMITS = Object.freeze({ total: 12, genre: 3, instrument: 5, words: 5, characters: 40 });
|
|
12
|
+
export const BPM_RANGE = Object.freeze({ min: 40, max: 220 });
|
|
13
|
+
|
|
14
|
+
// Words for singing: the generator makes instrumentals only, and asking for a voice makes it worse.
|
|
15
|
+
const VOCAL_WORDS = /\b(?:vocals?|vocalists?|singing|singers?|sung|voices?|choirs?|chorus|rap|rapping|lyrics?|spoken)\b/iu;
|
|
16
|
+
// Letters, digits, spaces and the joiners of ordinary descriptors; no quotes, brackets or tags.
|
|
17
|
+
const DESCRIPTOR_SHAPE = /^[\p{L}\p{N}][\p{L}\p{N}\p{M} '&/-]*$/u;
|
|
18
|
+
const TITLE_SHAPE = /^[\p{L}\p{N}][\p{L}\p{N}\p{M} ',.&/:-]*$/u;
|
|
19
|
+
|
|
20
|
+
const clean = (value) => String(value ?? "").normalize("NFC").replace(/\s+/gu, " ").trim();
|
|
21
|
+
|
|
22
|
+
// Words for a time of day, and the parts of the listener's day each fits. A term the agent credits
|
|
23
|
+
// to the moment that names another time came from the listener's words ("tonight"), not the clock.
|
|
24
|
+
const TIMES_OF_DAY = [
|
|
25
|
+
[/\b(?:night|nighttime|nocturnal|midnight|late-night|after hours|small hours|moonlit)\b/u, ["late_night", "evening"]],
|
|
26
|
+
[/\b(?:dusk|sunset|twilight|evening)\b/u, ["evening"]],
|
|
27
|
+
[/\b(?:dawn|sunrise|daybreak)\b/u, ["early_morning"]],
|
|
28
|
+
[/\b(?:morning|breakfast)\b/u, ["early_morning", "morning"]],
|
|
29
|
+
[/\b(?:afternoon|midday|noon)\b/u, ["afternoon"]],
|
|
30
|
+
];
|
|
31
|
+
const otherTimeOfDay = (term, partOfDay) => TIMES_OF_DAY.some(([words, parts]) => words.test(term) && !parts.includes(partOfDay));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The name a descriptor or a title carries, or null. artists and titles are the ones the listener
|
|
35
|
+
* chose or plays now, matched anywhere in the text. historyArtists are every artist they ever
|
|
36
|
+
* played: many short ones are ordinary words ("Low", "Air"), so a one-word name under five letters
|
|
37
|
+
* counts only as the whole text. historyTitles are their songs and albums of two words or more; a
|
|
38
|
+
* one-word title ("Home", "Rain") is too often an ordinary word to keep out. A name the history does not know can still pass; the shape
|
|
39
|
+
* limits keep a descriptor to a few plain words.
|
|
40
|
+
*/
|
|
41
|
+
export function namedBy(text, { artists = [], titles = [], historyArtists = [], historyTitles = [] } = {}) {
|
|
42
|
+
for (const artist of artists) if (textNamesArtist(text, artist)) return artist;
|
|
43
|
+
for (const title of [...titles, ...historyTitles]) if (textNames(text, title)) return title;
|
|
44
|
+
const whole = avoidKey(text);
|
|
45
|
+
for (const artist of historyArtists) {
|
|
46
|
+
const short = !/\s/u.test(artist.trim()) && Array.from(artist.trim()).length < 5;
|
|
47
|
+
if (short ? avoidKey(artist) === whole : textNamesArtist(text, artist)) return artist;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compiles one request to make a track. requested is what the agent proposed:
|
|
54
|
+
* { descriptors: [{ term, facet, source, from? }], bpm?, bpm_source?, title? }. context is what
|
|
55
|
+
* the host holds: { claim(id) → claim or null, revisionId, likes, avoids, recentArtists, recentWindow,
|
|
56
|
+
* historyArtists, historyTitles, avoidsName(name), moment, request }. Returns { usable, descriptors, dropped, bpm, title }; nothing is made unless usable.
|
|
57
|
+
*/
|
|
58
|
+
export function compileTaste(requested, context) {
|
|
59
|
+
const likes = context.likes ?? [];
|
|
60
|
+
const avoids = context.avoids ?? [];
|
|
61
|
+
// Every Avoid's names stay out of the prompt. As a source, an artist Avoid keeps out the artist,
|
|
62
|
+
// and a song Avoid only that song, as the play paths' gate reads them: a Like of the song's artist still counts.
|
|
63
|
+
const avoidLabels = avoids.flatMap((choice) => [choice.name, choice.artist].filter(Boolean));
|
|
64
|
+
const artistAvoids = avoids.filter((choice) => choice.subject !== "track").map((choice) => choice.name).filter(Boolean);
|
|
65
|
+
const songAvoids = avoids.filter((choice) => choice.subject === "track" && choice.name);
|
|
66
|
+
const avoided = (name) => Boolean(name) && (artistAvoids.some((label) => avoidKey(label) === avoidKey(name)) ||
|
|
67
|
+
songAvoids.some((song) => avoidKey(song.name) === avoidKey(name)) || context.avoidsName?.(name) === true);
|
|
68
|
+
const namesAvoid = (text) => artistAvoids.some((label) => textNamesArtist(text, label)) ||
|
|
69
|
+
songAvoids.some((song) => textNames(text, song.name) && (!song.artist || textNamesArtist(text, song.artist)));
|
|
70
|
+
const names = {
|
|
71
|
+
artists: [...new Set([...avoidLabels, ...likes.flatMap((like) => [like.subject === "artist" ? like.name : like.artist]),
|
|
72
|
+
...(context.recentArtists ?? []).map((artist) => artist.name)].filter(Boolean))],
|
|
73
|
+
titles: [...new Set([...likes, ...avoids].filter((choice) => choice.subject === "track").map((choice) => choice.name).filter(Boolean))],
|
|
74
|
+
historyArtists: [...new Set(context.historyArtists ?? [])].filter((name) => typeof name === "string" && name.trim()),
|
|
75
|
+
historyTitles: [...new Set(context.historyTitles ?? [])].filter((title) => typeof title === "string" && /\S\s+\S/u.test(title.trim())),
|
|
76
|
+
};
|
|
77
|
+
// A request that names an artist or a song the listener keeps out cannot shape the sound.
|
|
78
|
+
const requestAvoided = typeof context.request === "string" && namesAvoid(context.request);
|
|
79
|
+
const kept = [];
|
|
80
|
+
const dropped = [];
|
|
81
|
+
const drop = (term, reason, extra = {}) => dropped.push({ term, reason, ...extra });
|
|
82
|
+
const facetCount = (facet) => kept.filter((descriptor) => descriptor.facet === facet).length;
|
|
83
|
+
|
|
84
|
+
// A term that lists several sounds ("hushed, unhurried") is several descriptors with one source.
|
|
85
|
+
const proposals = (Array.isArray(requested?.descriptors) ? requested.descriptors : []).flatMap((proposed) => {
|
|
86
|
+
const parts = clean(proposed?.term).split(/\s*[,;,;、]\s*/u).filter(Boolean);
|
|
87
|
+
return parts.length > 1 ? parts.map((part) => ({ ...proposed, term: part })) : [proposed];
|
|
88
|
+
}).slice(0, DESCRIPTOR_LIMITS.total * 2);
|
|
89
|
+
for (const proposed of proposals) {
|
|
90
|
+
const term = clean(proposed?.term).toLocaleLowerCase("und");
|
|
91
|
+
const facet = proposed?.facet;
|
|
92
|
+
const kind = proposed?.source;
|
|
93
|
+
const from = clean(proposed?.from);
|
|
94
|
+
if (!term || !DESCRIPTOR_FACETS.includes(facet) || !DESCRIPTOR_SOURCES.includes(kind)) { drop(term, "invalid"); continue; }
|
|
95
|
+
if (Array.from(term).length > DESCRIPTOR_LIMITS.characters || term.split(" ").length > DESCRIPTOR_LIMITS.words || !DESCRIPTOR_SHAPE.test(term)) {
|
|
96
|
+
drop(term, "shape"); continue;
|
|
97
|
+
}
|
|
98
|
+
if (VOCAL_WORDS.test(term)) { drop(term, "vocals"); continue; }
|
|
99
|
+
const name = namedBy(term, names);
|
|
100
|
+
if (name) { drop(term, avoided(name) ? "avoid" : "name"); continue; }
|
|
101
|
+
if (kept.some((descriptor) => descriptor.term === term)) { drop(term, "repeated"); continue; }
|
|
102
|
+
if (kept.length >= DESCRIPTOR_LIMITS.total || (DESCRIPTOR_LIMITS[facet] !== undefined && facetCount(facet) >= DESCRIPTOR_LIMITS[facet])) {
|
|
103
|
+
drop(term, "too_many"); continue;
|
|
104
|
+
}
|
|
105
|
+
let source;
|
|
106
|
+
if (kind === "reading") {
|
|
107
|
+
const claim = from ? context.claim?.(from) : null;
|
|
108
|
+
if (!claim) { drop(term, "not_a_saved_finding"); continue; }
|
|
109
|
+
// A finding about an artist the listener keeps out is no source for the sound they want.
|
|
110
|
+
if (claim.stance === "avoid" || namesAvoid(claim.statement ?? "")) { drop(term, "avoid"); continue; }
|
|
111
|
+
source = { kind, claim_id: claim.claim_id, ...(context.revisionId ? { revision_id: context.revisionId } : {}) };
|
|
112
|
+
} else if (kind === "choice") {
|
|
113
|
+
if (avoided(from)) { drop(term, "avoid"); continue; }
|
|
114
|
+
const like = likes.find((choice) => [choice.name, choice.subject === "track" ? null : choice.artist].some((label) => label && avoidKey(label) === avoidKey(from)));
|
|
115
|
+
if (!like) { drop(term, "not_a_like"); continue; }
|
|
116
|
+
// A Like of a song whose artist the listener now keeps out no longer shapes anything.
|
|
117
|
+
if (like.subject === "track" && like.artist && (artistAvoids.some((label) => avoidKey(label) === avoidKey(like.artist)) || context.avoidsName?.(like.artist) === true)) {
|
|
118
|
+
drop(term, "avoid"); continue;
|
|
119
|
+
}
|
|
120
|
+
source = { kind, stance: "like", subject: like.subject, label: like.name, ...(like.artist ? { artist: like.artist } : {}) };
|
|
121
|
+
} else if (kind === "recent") {
|
|
122
|
+
if (avoided(from)) { drop(term, "avoid"); continue; }
|
|
123
|
+
const artist = (context.recentArtists ?? []).find((entry) => avoidKey(entry.name) === avoidKey(from));
|
|
124
|
+
if (!artist) { drop(term, "not_in_recent_attention"); continue; }
|
|
125
|
+
source = { kind, artist: artist.name, ...(context.recentWindow ?? {}) };
|
|
126
|
+
} else if (kind === "moment" && !otherTimeOfDay(term, context.moment?.part_of_day)) {
|
|
127
|
+
if (!context.moment) { drop(term, "no_clock"); continue; }
|
|
128
|
+
source = { kind, ...context.moment };
|
|
129
|
+
} else {
|
|
130
|
+
if (requestAvoided) { drop(term, "avoid"); continue; }
|
|
131
|
+
source = { kind: "request" };
|
|
132
|
+
}
|
|
133
|
+
kept.push({ term, facet, source });
|
|
134
|
+
}
|
|
135
|
+
// A rhythm alone describes no music; nothing is made, and the result says what was dropped and why.
|
|
136
|
+
const usable = kept.some((descriptor) => ["genre", "instrument", "mood", "texture"].includes(descriptor.facet));
|
|
137
|
+
const bpm = Number.isInteger(requested?.bpm) && requested.bpm >= BPM_RANGE.min && requested.bpm <= BPM_RANGE.max ? requested.bpm : null;
|
|
138
|
+
const bpmSource = bpm && ["request", "moment", "reading"].includes(requested?.bpm_source) ? requested.bpm_source : bpm ? "moondog" : null;
|
|
139
|
+
return { usable, descriptors: kept, dropped, bpm, ...(bpm ? { bpm_source: bpmSource } : {}), title: trackTitle(requested?.title, kept, names) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Moondog names its own records. A proposed title stays when it is short, plain and names no one;
|
|
143
|
+
// otherwise the title is the sound in a few words.
|
|
144
|
+
function trackTitle(proposed, descriptors, names) {
|
|
145
|
+
const title = clean(proposed);
|
|
146
|
+
if (title && Array.from(title).length <= 60 && TITLE_SHAPE.test(title) && !namedBy(title, names) && !VOCAL_WORDS.test(title)) return title;
|
|
147
|
+
const pick = (facets) => descriptors.find((descriptor) => facets.includes(descriptor.facet))?.term;
|
|
148
|
+
const words = [pick(["mood"]) ?? pick(["texture"]), pick(["genre"]) ?? pick(["instrument"])].filter(Boolean);
|
|
149
|
+
const text = [...new Set(words)].join(" ");
|
|
150
|
+
return text ? text[0].toUpperCase() + text.slice(1) : "Untitled";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The names in played tracks, for the name check: each credit whole and split, and each song and album title. */
|
|
154
|
+
export function listenedNames(tracks) {
|
|
155
|
+
const artists = new Set();
|
|
156
|
+
const titles = new Set();
|
|
157
|
+
for (const track of Array.isArray(tracks) ? tracks : []) {
|
|
158
|
+
const credit = typeof track?.artist_credit === "string" ? track.artist_credit.trim() : "";
|
|
159
|
+
if (credit) {
|
|
160
|
+
artists.add(credit);
|
|
161
|
+
for (const part of credit.split(ARTIST_CREDIT_SEPARATORS)) if (part?.trim()) artists.add(part.trim());
|
|
162
|
+
}
|
|
163
|
+
for (const title of [normalizedTrackTitle(track?.title), typeof track?.release === "string" ? track.release : track?.release?.title]) {
|
|
164
|
+
if (typeof title === "string" && title.trim()) titles.add(title.trim());
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { artists: [...artists], titles: [...titles] };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* One track of Moondog's stream from a compiled taste: every genre, and a few of the other sounds,
|
|
172
|
+
* a different few each time, so the tracks differ in their prompts and not only their seeds.
|
|
173
|
+
* leaveOut holds the sounds of tracks the listener skipped; they stay out while any other sound is left.
|
|
174
|
+
* The tempo drifts a little around the taste's own.
|
|
175
|
+
*/
|
|
176
|
+
export function streamVariation({ descriptors = [], bpm = null } = {}, position = 0, leaveOut = new Set()) {
|
|
177
|
+
const genres = descriptors.filter((descriptor) => descriptor.facet === "genre");
|
|
178
|
+
const others = descriptors.filter((descriptor) => descriptor.facet !== "genre");
|
|
179
|
+
const kept = others.filter((descriptor) => !leaveOut.has(descriptor.term));
|
|
180
|
+
const pool = kept.length ? kept : others;
|
|
181
|
+
// Each track leaves at least one sound out, so the next one can feature it and the stream moves.
|
|
182
|
+
const size = pool.length > 1 ? Math.min(3, pool.length - 1) : pool.length;
|
|
183
|
+
const from = pool.length ? (position * size) % pool.length : 0;
|
|
184
|
+
const featured = Array.from({ length: size }, (_, index) => pool[(from + index) % pool.length]);
|
|
185
|
+
const drift = [0, -3, 3, -5, 5][position % 5];
|
|
186
|
+
return {
|
|
187
|
+
descriptors: [...genres, ...featured],
|
|
188
|
+
featured: featured.map((descriptor) => descriptor.term),
|
|
189
|
+
bpm: Number.isInteger(bpm) ? Math.min(BPM_RANGE.max, Math.max(BPM_RANGE.min, bpm + drift)) : null,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** What the receipt and the tool result say shaped the sound, in order of the profile's layers. */
|
|
194
|
+
export function tasteSources(descriptors) {
|
|
195
|
+
const order = ["reading", "choice", "recent", "moment", "request"];
|
|
196
|
+
return order.filter((kind) => descriptors.some((descriptor) => descriptor.source.kind === kind));
|
|
197
|
+
}
|