@audiofool/moondog 0.3.0-beta.1 → 0.3.0-beta.3
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/README.md +1 -1
- package/package.json +1 -1
- package/scripts/moondog.mjs +98 -5
- package/src/core/listening-now.mjs +75 -6
- package/src/core/moondog-application.mjs +63 -18
- package/src/i18n/messages/de.mjs +1 -0
- package/src/i18n/messages/en.mjs +2 -0
- package/src/i18n/messages/es.mjs +1 -0
- package/src/i18n/messages/fr.mjs +1 -0
- package/src/i18n/messages/it.mjs +1 -0
- package/src/i18n/messages/ja.mjs +1 -0
- package/src/i18n/messages/ko.mjs +1 -0
- package/src/i18n/messages/pt.mjs +1 -0
- package/src/i18n/messages/zh-Hant.mjs +1 -0
- package/src/i18n/messages/zh.mjs +1 -0
- package/src/i18n/screen/es.mjs +65 -0
- package/src/i18n/screen/ja.mjs +65 -0
- package/src/i18n/screen/pt.mjs +65 -0
- package/src/i18n/screen/zh.mjs +65 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +10 -0
- package/src/integrations/netease/ncm-cli-player.mjs +12 -6
- package/src/integrations/stable-audio/local-generator.mjs +216 -80
- package/src/integrations/stable-audio/setup.mjs +653 -0
- package/src/integrations/stable-audio/sources.mjs +106 -0
- package/src/runtime/pi/agent-runtime.mjs +28 -10
- package/src/surfaces/cli/command-completions.mjs +15 -1
- package/src/surfaces/cli/tui.mjs +300 -18
package/README.md
CHANGED
package/package.json
CHANGED
package/scripts/moondog.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { runListenerProfileCommand, savedProfileActions } from "../src/surfaces/
|
|
|
34
34
|
import { createWebResearch } from "../src/integrations/web/web-research.mjs";
|
|
35
35
|
import { runWebCommand } from "../src/surfaces/cli/web-command.mjs";
|
|
36
36
|
import { openSpotifyConnection } from "../src/integrations/spotify/connection.mjs";
|
|
37
|
+
import { preferredIdleDevice, thisComputerName } from "../src/integrations/spotify/service.mjs";
|
|
37
38
|
import {
|
|
38
39
|
openSpotifyResolutionCache,
|
|
39
40
|
} from "../src/integrations/spotify/resolution-cache.mjs";
|
|
@@ -54,6 +55,7 @@ import { createAppleMusicCatalog } from "../src/integrations/apple-music/catalog
|
|
|
54
55
|
import { createTerminalPreviewPlayer } from "../src/integrations/apple-music/terminal-preview-player.mjs";
|
|
55
56
|
import { createNeteasePlayer } from "../src/integrations/netease/ncm-cli-player.mjs";
|
|
56
57
|
import { createAtmosphereStream, createLocalGenerator } from "../src/integrations/stable-audio/local-generator.mjs";
|
|
58
|
+
import { createStableAudioSetup } from "../src/integrations/stable-audio/setup.mjs";
|
|
57
59
|
import { createMusicAppPlayer } from "../src/integrations/apple-music/music-app.mjs";
|
|
58
60
|
import {
|
|
59
61
|
createOpenMusicSimilarity,
|
|
@@ -452,6 +454,76 @@ async function emitTasteprintArtifact(
|
|
|
452
454
|
return result;
|
|
453
455
|
}
|
|
454
456
|
|
|
457
|
+
// While the terminal's stream runs, the Music app is read this often. Only a change from not playing
|
|
458
|
+
// to playing ends the stream, and only in a read that began after the stream last changed and past its
|
|
459
|
+
// start: the song the first track pauses keeps playing while that track is made.
|
|
460
|
+
const STREAM_MUSIC_APP_MS = 5_000;
|
|
461
|
+
function watchMusicAppForStream(stream, { musicApp, interval = STREAM_MUSIC_APP_MS }) {
|
|
462
|
+
if (musicApp?.available !== true) return () => {};
|
|
463
|
+
let timer = null;
|
|
464
|
+
let reading = false;
|
|
465
|
+
let playing = null;
|
|
466
|
+
let phase = null;
|
|
467
|
+
let changedAt = 0;
|
|
468
|
+
const read = async () => {
|
|
469
|
+
if (reading) return;
|
|
470
|
+
reading = true;
|
|
471
|
+
const began = Date.now();
|
|
472
|
+
try {
|
|
473
|
+
const now = await musicApp.nowPlaying().catch(() => null);
|
|
474
|
+
const view = stream.view();
|
|
475
|
+
if (!view || began <= changedAt) return;
|
|
476
|
+
const playingNow = now?.state === "playing";
|
|
477
|
+
if (playingNow && playing === false && view.phase !== "starting") stream.stop("other_music");
|
|
478
|
+
playing = playingNow;
|
|
479
|
+
} finally {
|
|
480
|
+
reading = false;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
const unsubscribe = stream.subscribe(({ view }) => {
|
|
484
|
+
changedAt = Date.now();
|
|
485
|
+
// The first track just paused the Music app, so playing at a later read is news.
|
|
486
|
+
if (phase === "starting" && view && view.phase !== "starting") playing = false;
|
|
487
|
+
phase = view?.phase ?? null;
|
|
488
|
+
if (view && !timer) {
|
|
489
|
+
playing = null;
|
|
490
|
+
timer = setInterval(() => void read(), interval);
|
|
491
|
+
timer.unref?.();
|
|
492
|
+
} else if (!view && timer) {
|
|
493
|
+
clearInterval(timer);
|
|
494
|
+
timer = null;
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
return () => {
|
|
498
|
+
unsubscribe();
|
|
499
|
+
clearInterval(timer);
|
|
500
|
+
timer = null;
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Whether a /spotify command starts Spotify on this Mac: play on the device it resumes on (the active one,
|
|
505
|
+
// or with none active the one it would pick), or a move onto this Mac with --play or while Spotify plays (a
|
|
506
|
+
// move keeps playing what plays). A command its own checks refuse, or reads that fail, start nothing here.
|
|
507
|
+
async function spotifyStartsHere(application, args, { signal } = {}) {
|
|
508
|
+
if (!application.spotifyReady?.()) return false;
|
|
509
|
+
// A trailing --json changes only how the result is written.
|
|
510
|
+
if (args.at(-1) === "--json") args = args.slice(0, -1);
|
|
511
|
+
const play = args[0] === "play" && args.length <= 2;
|
|
512
|
+
const transfer = args[0] === "transfer" && args.length >= 2 && args.length <= 3 && (args.length === 2 || args[2] === "--play");
|
|
513
|
+
if (!play && !transfer) return false;
|
|
514
|
+
const service = application.requireSpotifyService();
|
|
515
|
+
const [listed, player] = await Promise.all([service.devices({ signal }).catch(() => null), service.currentPlayer({ signal }).catch(() => null)]);
|
|
516
|
+
const devices = listed?.devices ?? [];
|
|
517
|
+
if (transfer) {
|
|
518
|
+
const device = devices.find((entry) => entry.id === args[1]);
|
|
519
|
+
const playing = player?.state === "available" && player.is_playing === true;
|
|
520
|
+
return Boolean(device) && application.spotifyOnThisMac(device) && (args[2] === "--play" || playing);
|
|
521
|
+
}
|
|
522
|
+
const active = player?.device?.is_active === true ? player.device : devices.find((device) => device.is_active === true);
|
|
523
|
+
const device = active ?? preferredIdleDevice(devices, { thisComputer: await thisComputerName() });
|
|
524
|
+
return Boolean(device) && application.spotifyOnThisMac(device);
|
|
525
|
+
}
|
|
526
|
+
|
|
455
527
|
async function main() {
|
|
456
528
|
const updater = createApplicationUpdater();
|
|
457
529
|
const argv = process.argv.slice(2);
|
|
@@ -851,10 +923,14 @@ async function main() {
|
|
|
851
923
|
const neteasePlayer = createNeteasePlayer();
|
|
852
924
|
const playbackService = await resolvePlaybackService();
|
|
853
925
|
if (playbackService === "netease") await neteasePlayer.check();
|
|
854
|
-
// Moondog makes music on this Mac with
|
|
855
|
-
//
|
|
856
|
-
//
|
|
926
|
+
// Moondog makes music on this Mac with Stable Audio 3 once the listener turns it on, in a room
|
|
927
|
+
// that can play what it makes: Moondog's own install, downloaded when they ask, or their folder.
|
|
928
|
+
// The terminal and the window each keep one generator, so each makes one track at a time; a
|
|
929
|
+
// choice saved on one side is checked again on the other.
|
|
857
930
|
const localGenerator = previewPlayer ? createLocalGenerator() : null;
|
|
931
|
+
const generationSetup = localGenerator ? createStableAudioSetup() : null;
|
|
932
|
+
// A model set up or removed, here or on the other side, is checked again.
|
|
933
|
+
const unwatchGenerationSetup = generationSetup?.subscribe(() => { void localGenerator.check().catch(() => {}); }) ?? (() => {});
|
|
858
934
|
await localGenerator?.check();
|
|
859
935
|
void localGenerator?.sweep().catch(() => {});
|
|
860
936
|
const unwatchGeneration = localGenerator?.watch() ?? (() => {});
|
|
@@ -869,11 +945,15 @@ async function main() {
|
|
|
869
945
|
const unobserveStream = atmosphereStream ? previewPlayer.subscribe?.((event) => atmosphereStream.observe(event)) : null;
|
|
870
946
|
// Music making turned off, here or on the other surface, ends the stream.
|
|
871
947
|
const unwatchStreamGeneration = atmosphereStream ? localGenerator.subscribe((status) => { if (status.state !== "ready") atmosphereStream.stop("generation_off"); }) : null;
|
|
948
|
+
// The window watches the Music app for its own stream; the terminal has no window, so while its
|
|
949
|
+
// stream runs it reads the Music app itself, and music the listener starts there ends the stream.
|
|
950
|
+
const unwatchStreamMusicApp = atmosphereStream && !host ? watchMusicAppForStream(atmosphereStream, { musicApp }) : null;
|
|
872
951
|
const playbackSelection = createPlaybackSelection({ service: playbackService, save: writePlaybackPreference });
|
|
873
952
|
// The terminal and the window each run this core on one playback.json. A service chosen on the
|
|
874
|
-
// other side reaches this side's picker and agents, and stops the
|
|
953
|
+
// other side reaches this side's picker and agents, and stops the Apple Music songs this side plays.
|
|
954
|
+
// Moondog's stream is not a service: its made tracks play on.
|
|
875
955
|
const unwatchPlayback = watchPlaybackPreference(playbackSelection, {
|
|
876
|
-
onChange: (service) => { if (service !== "apple_music"
|
|
956
|
+
onChange: (service) => { if (service !== "apple_music" && previewPlayer?.holdsCatalogSongs?.() !== false) previewPlayer?.stop?.(); },
|
|
877
957
|
});
|
|
878
958
|
// The main application and every conversation the window opens beside it are built from
|
|
879
959
|
// this one list, so they cannot drift apart. Each passes in what may change after launch:
|
|
@@ -1083,7 +1163,12 @@ async function main() {
|
|
|
1083
1163
|
status: () => localGenerator.status(),
|
|
1084
1164
|
check: () => localGenerator.check(),
|
|
1085
1165
|
save: (settings) => localGenerator.save(settings),
|
|
1166
|
+
// Uses an installed model of Moondog's own install.
|
|
1167
|
+
use: async (model) => (generationSetup?.status()?.models?.[model]?.state === "ready"
|
|
1168
|
+
? localGenerator.save({ source: "managed", model }) : localGenerator.check()),
|
|
1086
1169
|
subscribe: (listener) => localGenerator.subscribe(listener),
|
|
1170
|
+
// Download, cancel and remove, shared by the window and the terminal.
|
|
1171
|
+
setup: generationSetup,
|
|
1087
1172
|
},
|
|
1088
1173
|
// The room asks whether a Last.fm key is set, and saves one the listener pastes.
|
|
1089
1174
|
lastfm: {
|
|
@@ -1167,6 +1252,9 @@ async function main() {
|
|
|
1167
1252
|
return json ? `\`\`\`json\n${output.trim()}\n\`\`\`` : output.trim();
|
|
1168
1253
|
},
|
|
1169
1254
|
runSpotify: async (args, { signal, stderr } = {}) => {
|
|
1255
|
+
// /spotify play, and a move onto this Mac that plays there, start Spotify here: the other players go
|
|
1256
|
+
// quiet first. A command its own checks will refuse, or a move to another device, quiets nothing.
|
|
1257
|
+
if (await spotifyStartsHere(application, args, { signal })) await application.quietOtherPlayers("spotify", { signal });
|
|
1170
1258
|
let output = "";
|
|
1171
1259
|
const capture = {
|
|
1172
1260
|
write(value) {
|
|
@@ -1192,6 +1280,10 @@ async function main() {
|
|
|
1192
1280
|
} finally {
|
|
1193
1281
|
unwatchPlayback();
|
|
1194
1282
|
unwatchGeneration();
|
|
1283
|
+
unwatchGenerationSetup();
|
|
1284
|
+
// A download or uv run this process owns would keep it alive after the room closes; it stops and
|
|
1285
|
+
// resumes next time. One owned by the other window or terminal runs on.
|
|
1286
|
+
await generationSetup?.close?.();
|
|
1195
1287
|
// A made track still playing is stopped while the generator listens, so its outcome is kept.
|
|
1196
1288
|
if (localGenerator) {
|
|
1197
1289
|
previewPlayer?.stop?.();
|
|
@@ -1200,6 +1292,7 @@ async function main() {
|
|
|
1200
1292
|
unobservePlayer?.();
|
|
1201
1293
|
unobserveStream?.();
|
|
1202
1294
|
unwatchStreamGeneration?.();
|
|
1295
|
+
unwatchStreamMusicApp?.();
|
|
1203
1296
|
atmosphereStream?.stop("closed");
|
|
1204
1297
|
listeningUpkeep?.stop();
|
|
1205
1298
|
lyrics?.close();
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
// What the listener plays right now, wherever it plays:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
1
|
+
// What the listener plays right now, wherever it plays: Moondog's own player (the songs it played or
|
|
2
|
+
// queued, the tracks it made, its stream), the Music app on this Mac, Spotify on its active device,
|
|
3
|
+
// or NetEase through ncm-cli, with the playlist, album or queue it plays from.
|
|
4
|
+
// Two players rarely play at once. When they do, one that plays wins over a paused one, then
|
|
5
|
+
// Moondog's own player, which pauses the others when it starts, then the listener's chosen service,
|
|
6
|
+
// then this order.
|
|
7
|
+
export const LISTENING_ORDER = Object.freeze(["moondog", "apple_music", "spotify", "netease"]);
|
|
6
8
|
|
|
7
9
|
export function activeListening(sources, chosen) {
|
|
8
|
-
const order = [...new Set([chosen, ...LISTENING_ORDER])];
|
|
10
|
+
const order = [...new Set(["moondog", chosen, ...LISTENING_ORDER])];
|
|
9
11
|
const heard = sources.filter((source) => source.song && (source.state === "playing" || source.state === "paused"));
|
|
10
12
|
return heard.sort((a, b) => (a.state === "playing" ? 0 : 1) - (b.state === "playing" ? 0 : 1) ||
|
|
11
13
|
order.indexOf(a.service) - order.indexOf(b.service))[0] ?? null;
|
|
@@ -75,6 +77,21 @@ async function spotify(application, { signal }) {
|
|
|
75
77
|
};
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
// Moondog's own player: Apple Music songs in the window or the terminal, and the tracks Moondog made.
|
|
81
|
+
const madeBy = (track) => ({ title: track.title, artists: track.generated ? ["Moondog"] : track.artist_credit ? [track.artist_credit] : [] });
|
|
82
|
+
function moondogPlayer(application) {
|
|
83
|
+
const now = application.musicPreviewPlayer.nowPlaying();
|
|
84
|
+
if (!now?.song?.title) return { state: "stopped" };
|
|
85
|
+
const kind = !now.song.generated ? "moondog_queue" : application.streamView?.() ? "moondog_stream" : "moondog_made";
|
|
86
|
+
return {
|
|
87
|
+
state: now.state,
|
|
88
|
+
player: "Moondog's own player",
|
|
89
|
+
song: madeBy(now.song),
|
|
90
|
+
from: { kind, name: null, tracks_total: now.count, position: now.position },
|
|
91
|
+
next: now.next.map(madeBy),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
78
95
|
async function netease(application) {
|
|
79
96
|
const now = await application.neteaseNowPlaying();
|
|
80
97
|
if (now.state === "stopped" || !now.song) return { state: "stopped" };
|
|
@@ -91,6 +108,7 @@ async function netease(application) {
|
|
|
91
108
|
/** Every player Moondog can read, the one the listener hears first: { active, sources }. */
|
|
92
109
|
export async function readListeningNow(application, { signal } = {}) {
|
|
93
110
|
const readers = [
|
|
111
|
+
typeof application.musicPreviewPlayer?.nowPlaying === "function" ? ["moondog", () => moondogPlayer(application)] : null,
|
|
94
112
|
application.musicApp?.available ? ["apple_music", () => musicApp(application)] : null,
|
|
95
113
|
application.spotifyReady() ? ["spotify", () => spotify(application, { signal })] : null,
|
|
96
114
|
application.neteaseReady() ? ["netease", () => netease(application)] : null,
|
|
@@ -106,3 +124,54 @@ export async function readListeningNow(application, { signal } = {}) {
|
|
|
106
124
|
const active = activeListening(sources, application.playbackService);
|
|
107
125
|
return { active: active?.service ?? null, sources: active ? [active, ...sources.filter((source) => source !== active)] : sources };
|
|
108
126
|
}
|
|
127
|
+
|
|
128
|
+
// What Moondog's own player plays: the queue it was sent last, and where in it the player's own
|
|
129
|
+
// events put it. The window and the terminal players keep one each, so the agent can read the songs
|
|
130
|
+
// it played or queued, and the tracks it made, as it reads the Music app or Spotify.
|
|
131
|
+
|
|
132
|
+
const entry = (track) => ({
|
|
133
|
+
title: String(track?.title ?? "").trim(),
|
|
134
|
+
artist_credit: String(track?.artist_credit ?? "").trim(),
|
|
135
|
+
...(track?.generated === true ? { generated: true } : {}),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export function createPlayerNow() {
|
|
139
|
+
// null, or { tracks, index, state } where state is "loading", "playing" or "paused".
|
|
140
|
+
let queue = null;
|
|
141
|
+
return {
|
|
142
|
+
/** A new queue replaces the last one and starts from its first song. */
|
|
143
|
+
queued(tracks) {
|
|
144
|
+
const list = (Array.isArray(tracks) ? tracks : []).map(entry).filter((track) => track.title);
|
|
145
|
+
queue = list.length ? { tracks: list, index: 0, state: "loading" } : null;
|
|
146
|
+
},
|
|
147
|
+
/** A made track queued behind the last one, as Moondog's stream does. */
|
|
148
|
+
appended(track) {
|
|
149
|
+
const added = entry(track);
|
|
150
|
+
if (queue && added.title) queue.tracks.push(added);
|
|
151
|
+
},
|
|
152
|
+
/** One of the player's events: { type, position } moves it along; stopped empties it. */
|
|
153
|
+
observe(event) {
|
|
154
|
+
// The terminal's player announces each new queue with its songs; the window's does not.
|
|
155
|
+
if (event?.type === "queue" && Array.isArray(event.tracks)) { this.queued(event.tracks); return; }
|
|
156
|
+
if (!queue) return;
|
|
157
|
+
if (event?.type === "stopped") { queue = null; return; }
|
|
158
|
+
if (Number.isInteger(event?.position) && event.position >= 1 && event.position <= queue.tracks.length) queue.index = event.position - 1;
|
|
159
|
+
if (event?.type === "playing" || event?.type === "paused" || event?.type === "loading") queue.state = event.type;
|
|
160
|
+
},
|
|
161
|
+
clear() {
|
|
162
|
+
queue = null;
|
|
163
|
+
},
|
|
164
|
+
/** null when nothing is queued, or { state, song, position, count, next } with up to ten songs after it. */
|
|
165
|
+
read() {
|
|
166
|
+
if (!queue) return null;
|
|
167
|
+
return {
|
|
168
|
+
// A song still loading is the one the listener is about to hear.
|
|
169
|
+
state: queue.state === "paused" ? "paused" : "playing",
|
|
170
|
+
song: { ...queue.tracks[queue.index] },
|
|
171
|
+
position: queue.index + 1,
|
|
172
|
+
count: queue.tracks.length,
|
|
173
|
+
next: queue.tracks.slice(queue.index + 1, queue.index + 11).map((track) => ({ ...track })),
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -563,7 +563,7 @@ export class MoondogApplication {
|
|
|
563
563
|
const file = await this.localGenerator.trackFile(id);
|
|
564
564
|
if (!file) throw Object.assign(new Error("That track was part of a stream, which keeps only its newest tracks' audio; its receipt is still here."), { code: "made_track_audio_not_kept" });
|
|
565
565
|
// Playing one of Moondog's tracks again ends its stream, on every surface.
|
|
566
|
-
await this.#endStream("replaced");
|
|
566
|
+
const endedStream = await this.#endStream("replaced");
|
|
567
567
|
const title = String(receipt.title ?? "");
|
|
568
568
|
// Named means quoted, or the title with Moondog, as on every play path: an open request shares
|
|
569
569
|
// words with the plain titles Moondog gives its records ("something for a rainy day").
|
|
@@ -571,7 +571,9 @@ export class MoondogApplication {
|
|
|
571
571
|
await this.quietOtherPlayers("apple_music", { signal });
|
|
572
572
|
// A reply stopped before the track starts plays nothing, and no replay is recorded.
|
|
573
573
|
if (signal?.aborted) throw Object.assign(new Error("Nothing was played, because the reply was stopped."), { code: "generation_stopped" });
|
|
574
|
-
// Nothing waits between this check and the play; the replay is written once it plays.
|
|
574
|
+
// Nothing waits between this check and the play; the replay is written once it plays. The ended
|
|
575
|
+
// stream's track it cuts off, or its queued one that started meanwhile, says nothing about the listener.
|
|
576
|
+
if (endedStream) this.localGenerator.discard?.();
|
|
575
577
|
this.localGenerator.restart?.(id);
|
|
576
578
|
this.musicPreviewPlayer.playGenerated({ id, title, seconds: receipt.audio?.duration_seconds, file });
|
|
577
579
|
if (!named) this.#noteGeneratedPlay({ id, title });
|
|
@@ -590,7 +592,7 @@ export class MoondogApplication {
|
|
|
590
592
|
return { state: "not_made", reason: compiled.descriptors.length ? "needs_a_sound" : "no_traceable_descriptors", dropped: compiled.dropped };
|
|
591
593
|
}
|
|
592
594
|
const prompt = stableAudioPrompt(compiled);
|
|
593
|
-
await this.#endStream("replaced");
|
|
595
|
+
const endedStream = await this.#endStream("replaced");
|
|
594
596
|
const made = await this.localGenerator.generate({ prompt, seconds, title: compiled.title, receipt }, { signal, onProgress });
|
|
595
597
|
// A reply stopped as the track finished keeps the track, but plays nothing over what is on now.
|
|
596
598
|
await this.quietOtherPlayers("apple_music", { signal });
|
|
@@ -598,6 +600,8 @@ export class MoondogApplication {
|
|
|
598
600
|
const file = await this.localGenerator.trackFile(made.id);
|
|
599
601
|
// A reply stopped while the track was made, or while the other players paused, keeps the track but plays nothing.
|
|
600
602
|
if (signal?.aborted) throw Object.assign(new Error("The track was made but not played, because the reply was stopped."), { code: "generation_stopped" });
|
|
603
|
+
// The replaced stream's track it cuts off was stopped by Moondog, not left by the listener.
|
|
604
|
+
if (endedStream) this.localGenerator.discard?.();
|
|
601
605
|
this.musicPreviewPlayer.playGenerated({ id: made.id, title: made.title, seconds: made.duration_seconds, file });
|
|
602
606
|
this.#noteGeneratedPlay(made);
|
|
603
607
|
return { state: "playing", track: { id: made.id, title: made.title, seconds: made.duration_seconds, seed: made.seed },
|
|
@@ -640,18 +644,29 @@ export class MoondogApplication {
|
|
|
640
644
|
}
|
|
641
645
|
await this.#endStream("replaced");
|
|
642
646
|
// Other music pauses only once the first track exists, as for a single track.
|
|
643
|
-
|
|
644
|
-
|
|
647
|
+
let started;
|
|
648
|
+
try {
|
|
649
|
+
started = await this.atmosphereStream.start({ compiled, title: compiled.title, seconds, minutes, receipt },
|
|
650
|
+
{ signal, onProgress, onTrack: (track) => this.#noteGeneratedPlay(track), beforePlay: () => this.quietOtherPlayers("apple_music", { signal }) });
|
|
651
|
+
} catch (error) {
|
|
652
|
+
// The listener turned it off while its first track was made: their choice, not a failure to retry.
|
|
653
|
+
if (error?.code === "generation_stopped" && error.ended === "asked" && !signal?.aborted) return { state: "not_made", reason: "stopped_by_listener" };
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
656
|
+
const { made, prompt } = started;
|
|
645
657
|
return { state: "streaming", minutes, track: { id: made.id, title: made.title, seconds: made.duration_seconds, seed: made.seed },
|
|
646
658
|
prompt, shaped_by: compiled.descriptors, sources: tasteSources(compiled.descriptors), left_out: compiled.dropped,
|
|
647
659
|
...(compiled.bpm ? { bpm: compiled.bpm } : {}), elapsed_ms: made.elapsed_ms };
|
|
648
660
|
}
|
|
649
661
|
|
|
650
662
|
// A single track or a new stream takes over from a running stream, once its next track stops being made.
|
|
663
|
+
// The stream's track plays on until the new one starts, so nothing goes silent while that is made.
|
|
664
|
+
// Says whether a stream ended.
|
|
651
665
|
async #endStream(reason) {
|
|
652
|
-
if (!this.atmosphereStream?.active?.()) return;
|
|
666
|
+
if (!this.atmosphereStream?.active?.()) return false;
|
|
653
667
|
this.atmosphereStream.stop(reason);
|
|
654
668
|
for (let waited = 0; this.localGenerator?.busy?.() && waited < 5_000; waited += 100) await delay(100);
|
|
669
|
+
return true;
|
|
655
670
|
}
|
|
656
671
|
|
|
657
672
|
stopStream() {
|
|
@@ -659,6 +674,21 @@ export class MoondogApplication {
|
|
|
659
674
|
return stopped ? { state: "stopped", tracks_made: stopped.tracks_made } : { state: "not_running" };
|
|
660
675
|
}
|
|
661
676
|
|
|
677
|
+
// What the window and the terminal show of the stream: { phase, started_at, ends_at, tracks_made }, or null.
|
|
678
|
+
streamView() {
|
|
679
|
+
return this.atmosphereStream?.view?.() ?? null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Each change of the stream, as { view, ended }; a host without a stream hears nothing.
|
|
683
|
+
subscribeStream(listener) {
|
|
684
|
+
return this.atmosphereStream?.subscribe?.(listener) ?? (() => {});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Whether a Spotify device is this Mac, where its music and Moondog's would play over each other.
|
|
688
|
+
spotifyOnThisMac(device) {
|
|
689
|
+
return spotifyOnThisMac(device);
|
|
690
|
+
}
|
|
691
|
+
|
|
662
692
|
// What the compiler may trace a descriptor to. Each cited finding is read from the saved reading.
|
|
663
693
|
async #tasteContext({ descriptors, moment, request }) {
|
|
664
694
|
const choices = this.domainServices?.listenerChoices?.()?.items ?? [];
|
|
@@ -708,10 +738,13 @@ export class MoondogApplication {
|
|
|
708
738
|
async playNetease({ items, mode, intent } = {}) {
|
|
709
739
|
const player = this.requireNetease();
|
|
710
740
|
this.requireListenerAvoids();
|
|
711
|
-
|
|
741
|
+
const quiet = () => this.quietOtherPlayers("netease");
|
|
742
|
+
if (mode !== "queue") await quiet();
|
|
712
743
|
// A found song lists its artists; a song asked for by name has only the name it was asked by.
|
|
713
|
-
|
|
714
|
-
|
|
744
|
+
// A queue with nothing loaded starts its first song, and quiets the others just before.
|
|
745
|
+
const receipt = await player.play({ items, mode, intent, ...(mode === "queue" ? { beforePlay: quiet } : {}),
|
|
746
|
+
allow: (song) => this.avoidAllows(Array.isArray(song.artists)
|
|
747
|
+
? { title: song.title, artist_credit: song.artists.join(", "), artists: song.artists } : song) });
|
|
715
748
|
if (["playing", "queued"].includes(receipt?.mode)) {
|
|
716
749
|
const songs = receipt.songs ?? [];
|
|
717
750
|
const slots = this.#mixSlots(songs.map((song) => ({ title: song.title, artists: song.artists })));
|
|
@@ -726,8 +759,13 @@ export class MoondogApplication {
|
|
|
726
759
|
|
|
727
760
|
async controlNetease({ action, positionSeconds } = {}) {
|
|
728
761
|
const player = this.requireNetease();
|
|
762
|
+
const quiet = () => this.quietOtherPlayers("netease");
|
|
763
|
+
if (action === "resume") await quiet();
|
|
729
764
|
if (action === "seek") await player.seek({ positionMs: Math.max(0, Number(positionSeconds) || 0) * 1000 });
|
|
730
|
-
|
|
765
|
+
// Next and previous start a song, so the other players go quiet just before; a refusal or a
|
|
766
|
+
// previous that only rewinds starts none.
|
|
767
|
+
else if (action === "next" || action === "previous") await player[action]({ beforePlay: quiet });
|
|
768
|
+
else if (["pause", "resume", "stop"].includes(action)) await player[action]();
|
|
731
769
|
else throw Object.assign(new Error(`Unknown NetEase action: ${action}`), { code: "netease_failed" });
|
|
732
770
|
return await player.nowPlaying();
|
|
733
771
|
}
|
|
@@ -736,9 +774,10 @@ export class MoondogApplication {
|
|
|
736
774
|
return this.requireNetease().nowPlaying();
|
|
737
775
|
}
|
|
738
776
|
|
|
739
|
-
// Whether any player the listener uses can be read: the Music app, Spotify or NetEase.
|
|
777
|
+
// Whether any player the listener uses can be read: Moondog's own, the Music app, Spotify or NetEase.
|
|
740
778
|
listeningReadable() {
|
|
741
|
-
return this.
|
|
779
|
+
return typeof this.musicPreviewPlayer?.nowPlaying === "function" || this.musicApp?.available === true ||
|
|
780
|
+
this.spotifyReady() || this.neteaseReady();
|
|
742
781
|
}
|
|
743
782
|
|
|
744
783
|
// What the listener plays right now on any of them, and what it plays from (src/core/listening-now.mjs).
|
|
@@ -829,12 +868,13 @@ export class MoondogApplication {
|
|
|
829
868
|
]);
|
|
830
869
|
}
|
|
831
870
|
|
|
832
|
-
// The service just left stops playing, so two services never play at once.
|
|
871
|
+
// The service just left stops playing, so two services never play at once. Moondog's stream is its
|
|
872
|
+
// own channel, not a service: its made tracks play on, and it ends only when a service song plays.
|
|
833
873
|
async #stopPlaybackService(service) {
|
|
834
874
|
try {
|
|
835
875
|
if (service === "apple_music") {
|
|
836
|
-
|
|
837
|
-
this.musicPreviewPlayer?.stop?.();
|
|
876
|
+
// Apple Music's songs in Moondog's player stop; a player that can't say what it holds stops, as before.
|
|
877
|
+
if (this.musicPreviewPlayer?.holdsCatalogSongs?.() !== false) this.musicPreviewPlayer?.stop?.();
|
|
838
878
|
// The listener's own Music app too, when it plays.
|
|
839
879
|
if ((await this.musicApp?.nowPlaying?.())?.state === "playing") await this.musicApp.pause();
|
|
840
880
|
} else if (service === "netease" && this.neteasePlayer?.ready?.() === true) await this.neteasePlayer.stop();
|
|
@@ -2093,8 +2133,6 @@ export class MoondogApplication {
|
|
|
2093
2133
|
this.requireSpotifyNonRemovalAction();
|
|
2094
2134
|
const transaction = this.pendingPlaylistPromptTransaction;
|
|
2095
2135
|
if (!transaction) throw spotifyResolutionError("spotify_queue_request_required", "Queueing runs inside a listener request.");
|
|
2096
|
-
// An idle device starts the first song.
|
|
2097
|
-
await this.quietOtherPlayers("spotify", { signal });
|
|
2098
2136
|
// The model acts on the listener's words, as for a single queue entry. A parsed
|
|
2099
2137
|
// count only sizes the batch; failing to parse a phrasing never blocks it.
|
|
2100
2138
|
const intent = transaction.queueIntent ?? null;
|
|
@@ -2146,6 +2184,10 @@ export class MoondogApplication {
|
|
|
2146
2184
|
const rest = new Set();
|
|
2147
2185
|
while (album && queue.at(-1)?.album_uri === album && !rest.has(queue.at(-1).uri)) rest.add(queue.pop().uri);
|
|
2148
2186
|
const seen = new Set([observed.currently_playing?.uri, ...queue.map(track => track.uri)].filter(Boolean));
|
|
2187
|
+
// With nothing playing and nothing queued, Spotify has dropped what Moondog queued (its device
|
|
2188
|
+
// went idle), so those songs were not heard: a new request may queue them again.
|
|
2189
|
+
// A player that could not be read says nothing, so the songs stay held back.
|
|
2190
|
+
if (!seen.size && player && player.is_playing !== true && !player.item) this.recentSimilarQueueUris.clear();
|
|
2149
2191
|
for (const [uri, acceptedAt] of this.recentSimilarQueueUris) {
|
|
2150
2192
|
if (Date.now() - acceptedAt > 15 * 60_000) this.recentSimilarQueueUris.delete(uri);
|
|
2151
2193
|
else seen.add(uri);
|
|
@@ -3289,8 +3331,11 @@ export class MoondogApplication {
|
|
|
3289
3331
|
const service = this.requireSpotifyService();
|
|
3290
3332
|
target ??= await service.queueTarget({ ...(deviceId ? { deviceId } : {}) }, { signal });
|
|
3291
3333
|
const device = target.deviceId ? { deviceId: target.deviceId } : {};
|
|
3292
|
-
// Nothing is playing, so a queue entry has nowhere to go: start this song instead.
|
|
3334
|
+
// Nothing is playing, so a queue entry has nowhere to go: start this song instead. That starts
|
|
3335
|
+
// music, so the other players go quiet first; a queue behind a song already there starts none.
|
|
3293
3336
|
const start = async () => {
|
|
3337
|
+
await this.quietOtherPlayers("spotify", { signal });
|
|
3338
|
+
signal?.throwIfAborted();
|
|
3294
3339
|
dispatching = true;
|
|
3295
3340
|
const receipt = await service.resume({ uris: [uri], ...device }, { signal });
|
|
3296
3341
|
started = label(track);
|
package/src/i18n/messages/de.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `„${title}“ von ${artist}` : artist; return `\n\nZurückgenommen: deine frühere Wahl zu ${name}.`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `Auf diesem Mac mit Stable Audio 3 gemacht: „${title}“ (${length}, Seed ${seed}).`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `Der Stream läuft auf diesem Mac weiter, jedes Mal mit einem neuen Stück, bis zu ${minutes} Minuten. Du kannst ihn jederzeit stoppen.`,
|
|
135
|
+
"onAir.request": "Geh auf Sendung: Mach neue Musik für jetzt und lass sie weiterlaufen",
|
|
135
136
|
"turn.cancelled": "Weitere Schritte abgebrochen.",
|
|
136
137
|
"turn.interrupted": "Die Antwort wurde unterbrochen. Die erfassten Spotify-Ergebnisse stehen unten; keine Aktion wurde wiederholt.",
|
|
137
138
|
};
|
package/src/i18n/messages/en.mjs
CHANGED
|
@@ -133,6 +133,8 @@ export default {
|
|
|
133
133
|
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" by ${artist}` : artist; return `\n\nUndone: your earlier choice about ${name}.`; },
|
|
134
134
|
"generate.made": ({ title, length, seed }) => `Made on this Mac with Stable Audio 3: "${title}" (${length}, seed ${seed}).`,
|
|
135
135
|
"generate.stream": ({ minutes }) => `The stream keeps going on this Mac, a new track each time, for up to ${minutes} minutes. Stop it any time.`,
|
|
136
|
+
// What the listener says to go on air, in the language the reply follows (/onair in the terminal).
|
|
137
|
+
"onAir.request": "Go on air: make new music for right now and keep it going",
|
|
136
138
|
"turn.cancelled": "Cancelled further work.",
|
|
137
139
|
"turn.interrupted": "The response was interrupted. The recorded Spotify outcomes are below; no action was replayed.",
|
|
138
140
|
};
|
package/src/i18n/messages/es.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" de ${artist}` : artist; return `\n\nDeshecho: tu elección anterior sobre ${name}.`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `Hecho en este Mac con Stable Audio 3: «${title}» (${length}, semilla ${seed}).`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `El flujo sigue en este Mac, con una pista nueva cada vez, hasta ${minutes} minutos. Puedes pararlo cuando quieras.`,
|
|
135
|
+
"onAir.request": "Sal al aire: crea música nueva para este momento y que siga sonando",
|
|
135
136
|
"turn.cancelled": "Cancelé el resto.",
|
|
136
137
|
"turn.interrupted": "La respuesta se interrumpió. Abajo están los resultados registrados en Spotify; no se repitió ninguna acción.",
|
|
137
138
|
};
|
package/src/i18n/messages/fr.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `« ${title} » de ${artist}` : artist; return `\n\nAnnulé : votre choix précédent sur ${name}.`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `Fait sur ce Mac avec Stable Audio 3 : « ${title} » (${length}, graine ${seed}).`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `Le flux continue sur ce Mac, avec un nouveau morceau à chaque fois, jusqu'à ${minutes} minutes. Vous pouvez l'arrêter à tout moment.`,
|
|
135
|
+
"onAir.request": "Prends l'antenne : crée de la musique nouvelle pour ce moment et fais-la durer",
|
|
135
136
|
"turn.cancelled": "Suite annulée.",
|
|
136
137
|
"turn.interrupted": "La réponse a été interrompue. Les résultats Spotify enregistrés figurent ci-dessous ; aucune action n'a été relancée.",
|
|
137
138
|
};
|
package/src/i18n/messages/it.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" di ${artist}` : artist; return `\n\nAnnullato: la tua scelta precedente su ${name}.`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `Fatto su questo Mac con Stable Audio 3: «${title}» (${length}, seed ${seed}).`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `Il flusso continua su questo Mac, con un brano nuovo ogni volta, fino a ${minutes} minuti. Puoi fermarlo quando vuoi.`,
|
|
135
|
+
"onAir.request": "Vai in onda: crea musica nuova per adesso e tienila accesa",
|
|
135
136
|
"turn.cancelled": "Ho annullato il resto.",
|
|
136
137
|
"turn.interrupted": "La risposta si è interrotta. Gli esiti di Spotify registrati sono qui sotto; nessuna azione è stata ripetuta.",
|
|
137
138
|
};
|
package/src/i18n/messages/ja.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `${artist}の「${title}」` : artist; return `\n\n取り消しました: ${name}についての前の選択。`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `この Mac の Stable Audio 3 で作りました:「${title}」(${length}、シード ${seed})。`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `この Mac で一曲ずつ新しい曲を作り続けます(最長 ${minutes} 分)。いつでも止められます。`,
|
|
135
|
+
"onAir.request": "オンエアして。今の気分に合わせて新しい音楽を作って、流し続けて",
|
|
135
136
|
"turn.cancelled": "以降の処理をキャンセルしました。",
|
|
136
137
|
"turn.interrupted": "返事が中断されました。記録された Spotify の操作結果は下のとおりです。操作は繰り返していません。",
|
|
137
138
|
};
|
package/src/i18n/messages/ko.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `${artist}의 "${title}"` : artist; return `\n\n취소했습니다: ${name}에 대한 이전 선택.`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `이 Mac의 Stable Audio 3로 만들었습니다: "${title}" (${length}, 시드 ${seed}).`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `이 Mac에서 매번 새 곡을 만들며 최대 ${minutes}분 동안 이어집니다. 언제든 멈출 수 있습니다.`,
|
|
135
|
+
"onAir.request": "방송 시작해 줘. 지금 분위기에 맞춰 새 음악을 만들어서 계속 틀어 줘",
|
|
135
136
|
"turn.cancelled": "남은 작업을 취소했습니다.",
|
|
136
137
|
"turn.interrupted": "답변이 중단되었습니다. 기록된 Spotify 결과는 아래와 같으며, 어떤 작업도 다시 실행하지 않았습니다.",
|
|
137
138
|
};
|
package/src/i18n/messages/pt.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" de ${artist}` : artist; return `\n\nDesfeito: sua escolha anterior sobre ${name}.`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `Feito neste Mac com o Stable Audio 3: "${title}" (${length}, semente ${seed}).`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `O fluxo continua neste Mac, com uma faixa nova a cada vez, por até ${minutes} minutos. Pare quando quiser.`,
|
|
135
|
+
"onAir.request": "Entre no ar: crie música nova para agora e mantenha tocando",
|
|
135
136
|
"turn.cancelled": "Cancelei o resto.",
|
|
136
137
|
"turn.interrupted": "A resposta foi interrompida. Abaixo estão os resultados registrados no Spotify; nenhuma ação foi repetida.",
|
|
137
138
|
};
|
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `${artist}的《${title}》` : artist; return `\n\n已撤銷:你之前對${name}的選擇。`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `用這台 Mac 上的 Stable Audio 3 做的:「${title}」(${length},種子 ${seed})。`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `音樂流會在這台 Mac 上一首接一首地做下去,最多 ${minutes} 分鐘,隨時可以停。`,
|
|
135
|
+
"onAir.request": "開播吧:照現在的感覺做新的音樂,一直放下去",
|
|
135
136
|
"turn.cancelled": "已取消後續操作。",
|
|
136
137
|
"turn.interrupted": "回覆中斷了。已記錄的 Spotify 結果如下;沒有重新執行任何操作。",
|
|
137
138
|
};
|
package/src/i18n/messages/zh.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `${artist}的《${title}》` : artist; return `\n\n已撤销:你之前对${name}的选择。`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `用这台 Mac 上的 Stable Audio 3 做的:「${title}」(${length},种子 ${seed})。`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `音乐流会在这台 Mac 上一首接一首地做下去,最多 ${minutes} 分钟,随时可以停。`,
|
|
135
|
+
"onAir.request": "开播吧:按现在的感觉做新的音乐,一直放下去",
|
|
135
136
|
"turn.cancelled": "已取消后续操作。",
|
|
136
137
|
"turn.interrupted": "回复中断;已确认的 Spotify 操作结果如下。未重新执行任何操作。",
|
|
137
138
|
};
|