@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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">Moondog</h1>
6
6
 
7
- <p align="center">Source candidate 0.3.0-beta.1</p>
7
+ <p align="center">Source candidate 0.3.0-beta.3</p>
8
8
 
9
9
  <p align="center"><strong>A listening room that shows its work.</strong></p>
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audiofool/moondog",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.0-beta.3",
4
4
  "type": "module",
5
5
  "description": "Local-first personal AI music agent.",
6
6
  "license": "Apache-2.0",
@@ -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 the listener's own Stable Audio 3 once they turn it on, in a
855
- // room that can play what it makes. The terminal and the window each keep one generator, so each
856
- // makes one track at a time; a choice saved on one side is checked again on the other.
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 previews this side plays.
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") { atmosphereStream?.stop("service_change"); previewPlayer?.stop?.(); } },
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: the Music app on this Mac, Spotify on its
2
- // active device, or NetEase through ncm-cli, with the playlist, album or queue it plays from.
3
- // Two players rarely play at once. When they do, one that plays wins over a paused one, then the
4
- // listener's chosen service, then this order.
5
- export const LISTENING_ORDER = Object.freeze(["apple_music", "spotify", "netease"]);
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
- const { made, prompt } = await this.atmosphereStream.start({ compiled, title: compiled.title, seconds, minutes, receipt },
644
- { signal, onProgress, onTrack: (track) => this.#noteGeneratedPlay(track), beforePlay: () => this.quietOtherPlayers("apple_music", { signal }) });
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
- if (mode !== "queue") await this.quietOtherPlayers("netease");
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
- const receipt = await player.play({ items, mode, intent, allow: (song) => this.avoidAllows(Array.isArray(song.artists)
714
- ? { title: song.title, artist_credit: song.artists.join(", "), artists: song.artists } : song) });
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
- else if (["pause", "resume", "next", "previous", "stop"].includes(action)) await player[action]();
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.musicApp?.available === true || this.spotifyReady() || this.neteaseReady();
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
- this.atmosphereStream?.stop?.("service_change");
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);
@@ -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
  };
@@ -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
  };
@@ -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
  };
@@ -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
  };
@@ -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
  };
@@ -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
  };
@@ -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
  };
@@ -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
  };
@@ -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
  };