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

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.2</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.2",
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";
@@ -452,6 +453,76 @@ async function emitTasteprintArtifact(
452
453
  return result;
453
454
  }
454
455
 
456
+ // While the terminal's stream runs, the Music app is read this often. Only a change from not playing
457
+ // to playing ends the stream, and only in a read that began after the stream last changed and past its
458
+ // start: the song the first track pauses keeps playing while that track is made.
459
+ const STREAM_MUSIC_APP_MS = 5_000;
460
+ function watchMusicAppForStream(stream, { musicApp, interval = STREAM_MUSIC_APP_MS }) {
461
+ if (musicApp?.available !== true) return () => {};
462
+ let timer = null;
463
+ let reading = false;
464
+ let playing = null;
465
+ let phase = null;
466
+ let changedAt = 0;
467
+ const read = async () => {
468
+ if (reading) return;
469
+ reading = true;
470
+ const began = Date.now();
471
+ try {
472
+ const now = await musicApp.nowPlaying().catch(() => null);
473
+ const view = stream.view();
474
+ if (!view || began <= changedAt) return;
475
+ const playingNow = now?.state === "playing";
476
+ if (playingNow && playing === false && view.phase !== "starting") stream.stop("other_music");
477
+ playing = playingNow;
478
+ } finally {
479
+ reading = false;
480
+ }
481
+ };
482
+ const unsubscribe = stream.subscribe(({ view }) => {
483
+ changedAt = Date.now();
484
+ // The first track just paused the Music app, so playing at a later read is news.
485
+ if (phase === "starting" && view && view.phase !== "starting") playing = false;
486
+ phase = view?.phase ?? null;
487
+ if (view && !timer) {
488
+ playing = null;
489
+ timer = setInterval(() => void read(), interval);
490
+ timer.unref?.();
491
+ } else if (!view && timer) {
492
+ clearInterval(timer);
493
+ timer = null;
494
+ }
495
+ });
496
+ return () => {
497
+ unsubscribe();
498
+ clearInterval(timer);
499
+ timer = null;
500
+ };
501
+ }
502
+
503
+ // Whether a /spotify command starts Spotify on this Mac: play on the device it resumes on (the active one,
504
+ // or with none active the one it would pick), or a move onto this Mac with --play or while Spotify plays (a
505
+ // move keeps playing what plays). A command its own checks refuse, or reads that fail, start nothing here.
506
+ async function spotifyStartsHere(application, args, { signal } = {}) {
507
+ if (!application.spotifyReady?.()) return false;
508
+ // A trailing --json changes only how the result is written.
509
+ if (args.at(-1) === "--json") args = args.slice(0, -1);
510
+ const play = args[0] === "play" && args.length <= 2;
511
+ const transfer = args[0] === "transfer" && args.length >= 2 && args.length <= 3 && (args.length === 2 || args[2] === "--play");
512
+ if (!play && !transfer) return false;
513
+ const service = application.requireSpotifyService();
514
+ const [listed, player] = await Promise.all([service.devices({ signal }).catch(() => null), service.currentPlayer({ signal }).catch(() => null)]);
515
+ const devices = listed?.devices ?? [];
516
+ if (transfer) {
517
+ const device = devices.find((entry) => entry.id === args[1]);
518
+ const playing = player?.state === "available" && player.is_playing === true;
519
+ return Boolean(device) && application.spotifyOnThisMac(device) && (args[2] === "--play" || playing);
520
+ }
521
+ const active = player?.device?.is_active === true ? player.device : devices.find((device) => device.is_active === true);
522
+ const device = active ?? preferredIdleDevice(devices, { thisComputer: await thisComputerName() });
523
+ return Boolean(device) && application.spotifyOnThisMac(device);
524
+ }
525
+
455
526
  async function main() {
456
527
  const updater = createApplicationUpdater();
457
528
  const argv = process.argv.slice(2);
@@ -869,11 +940,15 @@ async function main() {
869
940
  const unobserveStream = atmosphereStream ? previewPlayer.subscribe?.((event) => atmosphereStream.observe(event)) : null;
870
941
  // Music making turned off, here or on the other surface, ends the stream.
871
942
  const unwatchStreamGeneration = atmosphereStream ? localGenerator.subscribe((status) => { if (status.state !== "ready") atmosphereStream.stop("generation_off"); }) : null;
943
+ // The window watches the Music app for its own stream; the terminal has no window, so while its
944
+ // stream runs it reads the Music app itself, and music the listener starts there ends the stream.
945
+ const unwatchStreamMusicApp = atmosphereStream && !host ? watchMusicAppForStream(atmosphereStream, { musicApp }) : null;
872
946
  const playbackSelection = createPlaybackSelection({ service: playbackService, save: writePlaybackPreference });
873
947
  // 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.
948
+ // other side reaches this side's picker and agents, and stops the Apple Music songs this side plays.
949
+ // Moondog's stream is not a service: its made tracks play on.
875
950
  const unwatchPlayback = watchPlaybackPreference(playbackSelection, {
876
- onChange: (service) => { if (service !== "apple_music") { atmosphereStream?.stop("service_change"); previewPlayer?.stop?.(); } },
951
+ onChange: (service) => { if (service !== "apple_music" && previewPlayer?.holdsCatalogSongs?.() !== false) previewPlayer?.stop?.(); },
877
952
  });
878
953
  // The main application and every conversation the window opens beside it are built from
879
954
  // this one list, so they cannot drift apart. Each passes in what may change after launch:
@@ -1167,6 +1242,9 @@ async function main() {
1167
1242
  return json ? `\`\`\`json\n${output.trim()}\n\`\`\`` : output.trim();
1168
1243
  },
1169
1244
  runSpotify: async (args, { signal, stderr } = {}) => {
1245
+ // /spotify play, and a move onto this Mac that plays there, start Spotify here: the other players go
1246
+ // quiet first. A command its own checks will refuse, or a move to another device, quiets nothing.
1247
+ if (await spotifyStartsHere(application, args, { signal })) await application.quietOtherPlayers("spotify", { signal });
1170
1248
  let output = "";
1171
1249
  const capture = {
1172
1250
  write(value) {
@@ -1200,6 +1278,7 @@ async function main() {
1200
1278
  unobservePlayer?.();
1201
1279
  unobserveStream?.();
1202
1280
  unwatchStreamGeneration?.();
1281
+ unwatchStreamMusicApp?.();
1203
1282
  atmosphereStream?.stop("closed");
1204
1283
  listeningUpkeep?.stop();
1205
1284
  lyrics?.close();
@@ -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
  }
@@ -829,12 +867,13 @@ export class MoondogApplication {
829
867
  ]);
830
868
  }
831
869
 
832
- // The service just left stops playing, so two services never play at once.
870
+ // The service just left stops playing, so two services never play at once. Moondog's stream is its
871
+ // own channel, not a service: its made tracks play on, and it ends only when a service song plays.
833
872
  async #stopPlaybackService(service) {
834
873
  try {
835
874
  if (service === "apple_music") {
836
- this.atmosphereStream?.stop?.("service_change");
837
- this.musicPreviewPlayer?.stop?.();
875
+ // Apple Music's songs in Moondog's player stop; a player that can't say what it holds stops, as before.
876
+ if (this.musicPreviewPlayer?.holdsCatalogSongs?.() !== false) this.musicPreviewPlayer?.stop?.();
838
877
  // The listener's own Music app too, when it plays.
839
878
  if ((await this.musicApp?.nowPlaying?.())?.state === "playing") await this.musicApp.pause();
840
879
  } else if (service === "netease" && this.neteasePlayer?.ready?.() === true) await this.neteasePlayer.stop();
@@ -2093,8 +2132,6 @@ export class MoondogApplication {
2093
2132
  this.requireSpotifyNonRemovalAction();
2094
2133
  const transaction = this.pendingPlaylistPromptTransaction;
2095
2134
  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
2135
  // The model acts on the listener's words, as for a single queue entry. A parsed
2099
2136
  // count only sizes the batch; failing to parse a phrasing never blocks it.
2100
2137
  const intent = transaction.queueIntent ?? null;
@@ -3289,8 +3326,11 @@ export class MoondogApplication {
3289
3326
  const service = this.requireSpotifyService();
3290
3327
  target ??= await service.queueTarget({ ...(deviceId ? { deviceId } : {}) }, { signal });
3291
3328
  const device = target.deviceId ? { deviceId: target.deviceId } : {};
3292
- // Nothing is playing, so a queue entry has nowhere to go: start this song instead.
3329
+ // Nothing is playing, so a queue entry has nowhere to go: start this song instead. That starts
3330
+ // music, so the other players go quiet first; a queue behind a song already there starts none.
3293
3331
  const start = async () => {
3332
+ await this.quietOtherPlayers("spotify", { signal });
3333
+ signal?.throwIfAborted();
3294
3334
  dispatching = true;
3295
3335
  const receipt = await service.resume({ uris: [uri], ...device }, { signal });
3296
3336
  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: Lass die Musik für jetzt 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: keep the music going for right now",
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: que la música siga sonando para este momento",
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 : que la musique continue pour ce moment",
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: tieni la musica accesa per adesso",
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: mantenha a música tocando para agora",
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
  };
@@ -1053,4 +1053,11 @@ export default {
1053
1053
  "Play a track Moondog made": "Volver a poner una pista que hizo Moondog",
1054
1054
  "Start a stream": "Empezar un flujo",
1055
1055
  "Stop the stream": "Parar el flujo",
1056
+ "Go on air: Moondog keeps making music from your taste": "Sal al aire: Moondog sigue haciendo música a tu gusto",
1057
+ "on air until {time}": "al aire hasta las {time}",
1058
+ "Cutting the next record": "Grabando el siguiente disco",
1059
+ "Off air: the next record couldn't be made.": "Fuera del aire: no se pudo hacer el siguiente disco.",
1060
+ "The next record is still being cut.": "El siguiente disco aún se está grabando.",
1061
+ "Off air.": "Fuera del aire.",
1062
+ "Turn on music making first: /generation on.": "Primero activa la creación de música: /generation on.",
1056
1063
  };
@@ -1053,4 +1053,11 @@ export default {
1053
1053
  "Play a track Moondog made": "Moondog が作った曲をもう一度かける",
1054
1054
  "Start a stream": "音楽の流れを始める",
1055
1055
  "Stop the stream": "音楽の流れを止める",
1056
+ "Go on air: Moondog keeps making music from your taste": "オンエア:あなたの好みから Moondog が音楽を作り続ける",
1057
+ "on air until {time}": "{time} までオンエア",
1058
+ "Cutting the next record": "次のレコードをカッティング中",
1059
+ "Off air: the next record couldn't be made.": "オフエア:次のレコードを作れませんでした。",
1060
+ "The next record is still being cut.": "次のレコードはまだカッティング中です。",
1061
+ "Off air.": "オフエアにしました。",
1062
+ "Turn on music making first: /generation on.": "先に音楽作りをオンにしてください:/generation on。",
1056
1063
  };
@@ -1053,4 +1053,11 @@ export default {
1053
1053
  "Play a track Moondog made": "Tocar de novo uma faixa que o Moondog fez",
1054
1054
  "Start a stream": "Começar um fluxo",
1055
1055
  "Stop the stream": "Parar o fluxo",
1056
+ "Go on air: Moondog keeps making music from your taste": "Entre no ar: o Moondog segue fazendo música a partir do seu gosto",
1057
+ "on air until {time}": "no ar até {time}",
1058
+ "Cutting the next record": "Gravando o próximo disco",
1059
+ "Off air: the next record couldn't be made.": "Fora do ar: não deu para fazer o próximo disco.",
1060
+ "The next record is still being cut.": "O próximo disco ainda está sendo gravado.",
1061
+ "Off air.": "Fora do ar.",
1062
+ "Turn on music making first: /generation on.": "Ative a criação de música primeiro: /generation on.",
1056
1063
  };
@@ -1053,4 +1053,11 @@ export default {
1053
1053
  "Play a track Moondog made": "再放一首 Moondog 做的曲子",
1054
1054
  "Start a stream": "开始音乐流",
1055
1055
  "Stop the stream": "停止音乐流",
1056
+ "Go on air: Moondog keeps making music from your taste": "开播:Moondog 照着你的口味一直做音乐",
1057
+ "on air until {time}": "播出到 {time}",
1058
+ "Cutting the next record": "正在刻下一张",
1059
+ "Off air: the next record couldn't be made.": "停播了:下一张没刻成。",
1060
+ "The next record is still being cut.": "下一张还在刻。",
1061
+ "Off air.": "已停播。",
1062
+ "Turn on music making first: /generation on.": "先开启音乐生成:/generation on。",
1056
1063
  };
@@ -232,6 +232,8 @@ export async function createTerminalPreviewPlayer({
232
232
  return true;
233
233
  },
234
234
  stop,
235
+ // Leaving Apple Music stops its previews here, never the tracks Moondog made.
236
+ holdsCatalogSongs: () => Boolean(queue?.tracks.some((track) => !track.generated)),
235
237
  subscribe(listener) {
236
238
  listeners.add(listener);
237
239
  return () => listeners.delete(listener);
@@ -245,9 +245,10 @@ export function createNeteasePlayer({
245
245
  * up. mode "play" replaces the queue and starts the first; "queue" adds after what is
246
246
  * playing. Songs NetEase marks as not playable are never sent to the player, nor songs
247
247
  * allow(song) refuses, such as the listener's Avoids; those are only counted. Before a lookup,
248
- * allow gets the title and artist asked for, as { title, artist_credit }.
248
+ * allow gets the title and artist asked for, as { title, artist_credit }. beforePlay runs just
249
+ * before a song starts, which a queue with nothing loaded does too.
249
250
  */
250
- async play({ items = [], mode = "play", intent, allow = () => true } = {}) {
251
+ async play({ items = [], mode = "play", intent, allow = () => true, beforePlay } = {}) {
251
252
  const found = [];
252
253
  const unplayable = [];
253
254
  const notFound = [];
@@ -278,6 +279,7 @@ export function createNeteasePlayer({
278
279
  queue = [...queue, ...songs];
279
280
  outcome = "queued";
280
281
  } else {
282
+ await beforePlay?.();
281
283
  queue = songs;
282
284
  await playAt(0);
283
285
  outcome = "playing";
@@ -297,15 +299,19 @@ export function createNeteasePlayer({
297
299
  checked(await run(["resume"]), "resume");
298
300
  stayConnected();
299
301
  },
300
- async next() {
302
+ // beforePlay runs just before a song starts, so the other players go quiet only for one.
303
+ async next({ beforePlay } = {}) {
301
304
  if (index + 1 >= queue.length) throw failure("netease_failed", "Nothing is queued after this song.");
305
+ await beforePlay?.();
302
306
  await playAt(index + 1);
303
307
  },
304
308
  // Back to the start of the song, or to the one before when it has only just begun.
305
- async previous() {
309
+ async previous({ beforePlay } = {}) {
306
310
  const now = await state();
307
- if (index > 0 && (Number(now.position) || 0) <= 3) await playAt(index - 1);
308
- else checked(await run(["seek", "0"]), "go back to the start");
311
+ if (index > 0 && (Number(now.position) || 0) <= 3) {
312
+ await beforePlay?.();
313
+ await playAt(index - 1);
314
+ } else checked(await run(["seek", "0"]), "go back to the start");
309
315
  },
310
316
  async seek({ positionMs = 0 } = {}) {
311
317
  checked(await run(["seek", String(Math.max(0, Math.round(positionMs / 1000)))]), "move in the song");
@@ -401,9 +401,10 @@ export function createLocalGenerator({
401
401
  restart(id) {
402
402
  if (playing?.id === id) playing = null;
403
403
  },
404
- // A play Moondog cut off as it began, as a stream past its time limit does, says nothing about the listener.
404
+ // A play Moondog cut off as it began, as a stream past its time limit does, says nothing about the
405
+ // listener. Without an id, whatever made track plays now, as when a stream's first track replaces it.
405
406
  discard(id) {
406
- if (playing?.id === id) playing = null;
407
+ if (playing && (id === undefined || playing.id === id)) playing = null;
407
408
  },
408
409
  recordReplay(id, by) {
409
410
  this.restart(id);
@@ -510,12 +511,53 @@ export function createLocalGenerator({
510
511
  const STREAM_SKIP_MS = 30_000;
511
512
  export function createAtmosphereStream({ generator, player, now = Date.now, keepAudio = 3 }) {
512
513
  let stream = null;
514
+ // What the surfaces see (view()) and hear (subscribe): the phase, and the end with its reason.
515
+ const listeners = new Set();
516
+ let shown = "null";
517
+ // Events wait their turn, so a listener that stops the stream from inside one never reorders them.
518
+ const events = [];
519
+ let delivering = false;
520
+ const deliver = (event) => {
521
+ events.push(event);
522
+ if (delivering) return;
523
+ delivering = true;
524
+ try {
525
+ while (events.length) {
526
+ const next = events.shift();
527
+ for (const listener of [...listeners]) {
528
+ try { listener(structuredClone(next)); } catch { /* A surface's redraw cannot stop the stream. */ }
529
+ }
530
+ }
531
+ } finally {
532
+ delivering = false;
533
+ }
534
+ };
535
+ const viewOf = (current) => current ? {
536
+ // Nothing of it plays yet; it waits in the dry gap for its next track; it makes the next behind one loaded; or it only plays.
537
+ phase: !current.current ? "starting" : current.waiting ? "waiting" : current.making ? "making" : "playing",
538
+ started_at: new Date(current.startedAt).toISOString(), ends_at: new Date(current.endsAt).toISOString(), tracks_made: current.made.length,
539
+ } : null;
540
+ // Called after anything that may change the view; only a real change reaches the listeners.
541
+ const changed = () => {
542
+ const view = viewOf(stream);
543
+ const key = JSON.stringify(view);
544
+ if (key === shown) return;
545
+ shown = key;
546
+ deliver({ view, ended: null });
547
+ };
548
+ // The tracks of a stream that gave way to Moondog's next one, while the player may still hold one of
549
+ // them; a listener's stop before the new stream's first track plays stops them too.
550
+ let heldOver = null;
513
551
  const end = (reason) => {
514
552
  const ending = stream;
515
553
  if (!ending) return null;
516
554
  stream = null;
517
555
  ending.controller.abort();
518
556
  ending.ended = reason;
557
+ heldOver = reason === "replaced" && (ending.current || heldOver)
558
+ ? new Set([...(heldOver ?? []), ...ending.made.map((made) => made.id)]) : null;
559
+ shown = "null";
560
+ deliver({ view: null, ended: reason });
519
561
  return ending;
520
562
  };
521
563
  const trackFor = (ending, position) => {
@@ -564,6 +606,7 @@ export function createAtmosphereStream({ generator, player, now = Date.now, keep
564
606
  const current = stream;
565
607
  if (!current || current.making || current.next || !fits(current)) return;
566
608
  current.making = true;
609
+ changed();
567
610
  try {
568
611
  const { prompt, title, receipt, variation } = trackFor(current, current.made.length);
569
612
  const made = await generator.generate({ prompt, seconds: current.seconds, title, receipt }, { signal: current.controller.signal });
@@ -574,6 +617,8 @@ export function createAtmosphereStream({ generator, player, now = Date.now, keep
574
617
  if (current.waiting && expired(current)) { end("time"); return; }
575
618
  current.made.push({ id: made.id, featured: variation.featured, started: false, seconds: made.duration_seconds });
576
619
  current.next = made.id;
620
+ // Made, so what the player reports from here on sees the stream as only playing.
621
+ current.making = false;
577
622
  const track = { id: made.id, title: made.title, seconds: made.duration_seconds, file };
578
623
  // A queue that ran dry while this was made starts again with it.
579
624
  if (current.waiting) { current.waiting = false; player.playGenerated(track); } else player.enqueueGenerated(track);
@@ -583,6 +628,52 @@ export function createAtmosphereStream({ generator, player, now = Date.now, keep
583
628
  if (stream === current && error?.code !== "generation_stopped") end("failed");
584
629
  } finally {
585
630
  current.making = false;
631
+ changed();
632
+ }
633
+ };
634
+ // What the room's player reports moves the stream along or ends it.
635
+ const move = (event) => {
636
+ const current = stream;
637
+ // Until its first track plays, what the player reports is about the music before it.
638
+ if (!current || !current.current) return;
639
+ const track = event?.track;
640
+ const ours = (id) => current.made.some((made) => made.id === id);
641
+ // A queue or a song that is not one of the stream's tracks is other music, which ends the stream.
642
+ if (event?.type === "queue" || event?.type === "playing") {
643
+ const items = Array.isArray(event.tracks) ? event.tracks : track ? [track] : [];
644
+ if (items.some((item) => !item?.generated || (item.id && !ours(item.id)))) { end("other_music"); return; }
645
+ }
646
+ const isCurrent = track?.id && track.id === current.current.id;
647
+ if (event?.type === "playing" && track?.id && track.id === current.next) {
648
+ // The window may already have moved on to it when the time limit had passed. The generator
649
+ // has begun counting it, and Moondog's own stop must not read as the listener's skip.
650
+ if (expired(current)) { generator.discard?.(track.id); endInTime(); return; }
651
+ leftEarly(current);
652
+ const made = current.made.find((entry) => entry.id === track.id);
653
+ current.current = { id: track.id, heardMs: 0, since: now(), finished: false, seconds: made?.seconds };
654
+ if (made) made.started = true;
655
+ current.next = null;
656
+ current.waiting = false;
657
+ prune(current);
658
+ void makeNext();
659
+ } else if (event?.type === "paused" && isCurrent && current.current.since !== null) {
660
+ current.current.heardMs += now() - current.current.since;
661
+ current.current.since = null;
662
+ } else if (event?.type === "playing" && isCurrent && current.current.since === null) {
663
+ current.current.since = now();
664
+ } else if (event?.type === "skipped" && isCurrent) {
665
+ // A skip past the end of the queue moves on to the next track once it is made.
666
+ leftEarly(current);
667
+ current.current.finished = true;
668
+ if (!current.next) ranDry(current);
669
+ else if (expired(current)) endInTime();
670
+ } else if ((event?.type === "finished" || event?.type === "failed") && isCurrent) {
671
+ // A track that could not play says nothing about the listener; it is passed over like one that ended.
672
+ current.current.finished = true;
673
+ if (!current.next) ranDry(current);
674
+ else if (expired(current)) endInTime();
675
+ } else if (event?.type === "stopped" && !current.waiting && !current.starting) {
676
+ end("stopped");
586
677
  }
587
678
  };
588
679
  return {
@@ -593,17 +684,25 @@ export function createAtmosphereStream({ generator, player, now = Date.now, keep
593
684
  return { tracks_made: stream.made.length, started_at: new Date(stream.startedAt).toISOString(),
594
685
  ends_at: new Date(stream.endsAt).toISOString(), steering_away_from: [...stream.leaveOut] };
595
686
  },
687
+ /** For the surfaces: { phase, started_at, ends_at, tracks_made }, or null when no stream runs. */
688
+ view: () => viewOf(stream),
689
+ /** Hears { view, ended } once for each change: the start, each phase, each track made, and the end with its reason. */
690
+ subscribe(listener) {
691
+ listeners.add(listener);
692
+ return () => listeners.delete(listener);
693
+ },
596
694
  /**
597
695
  * Starts a stream from a compiled taste and plays its first track; the next is made at once.
598
696
  * beforePlay runs once the first track exists, so other music pauses only when there is one to
599
- * play. Returns the first track. A stream already running gives way to the new one.
697
+ * play. Returns the first track. A stream already running gives way to the new one, and its track
698
+ * plays on until the first new one takes over, so nothing goes silent while that is made.
600
699
  */
601
700
  async start({ compiled, title, seconds, minutes, receipt = {} }, { signal, onProgress, onTrack, beforePlay } = {}) {
602
- const previous = end("replaced");
603
- if (previous?.current) player.stop?.();
701
+ end("replaced");
604
702
  const current = { compiled, title, seconds, receipt, onTrack, startedAt: now(), endsAt: now() + minutes * 60_000,
605
703
  made: [], leaveOut: new Set(), current: null, next: null, waiting: false, making: true, controller: new AbortController() };
606
704
  stream = current;
705
+ changed();
607
706
  const stopped = () => Object.assign(new Error("The stream was stopped before it began."), { code: "generation_stopped" });
608
707
  try {
609
708
  const { prompt, title: first, receipt: firstReceipt, variation } = trackFor(current, 0);
@@ -617,67 +716,43 @@ export function createAtmosphereStream({ generator, player, now = Date.now, keep
617
716
  current.made.push({ id: made.id, featured: variation.featured, started: true, seconds: made.duration_seconds });
618
717
  current.current = { id: made.id, heardMs: 0, since: now(), finished: false, seconds: made.duration_seconds };
619
718
  current.making = false;
719
+ // The made track it replaces, a single one or the last stream's, was cut off by Moondog, not
720
+ // left by the listener: counted as a skip, it would steer later tracks away from its sound.
721
+ generator.discard?.();
620
722
  // Whatever was playing stops for the first track; that stop is not the listener's.
621
723
  current.starting = true;
622
724
  try { player.playGenerated({ id: made.id, title: made.title, seconds: made.duration_seconds, file }); } finally { current.starting = false; }
725
+ heldOver = null;
623
726
  onTrack?.(made);
624
727
  void makeNext();
728
+ changed();
625
729
  return { made, prompt, variation };
626
730
  } catch (error) {
627
- if (stream === current) end("failed");
731
+ // A reply stopped while the first track was made is the listener's own stop, not a failure.
732
+ if (stream === current) end(error?.code === "generation_stopped" ? "asked" : "failed");
733
+ // Why it ended tells the listener turning it off apart from a stop of the reply.
734
+ if (error?.code === "generation_stopped" && error.ended === undefined) error.ended = current.ended ?? null;
628
735
  throw error;
629
736
  }
630
737
  },
631
738
  stop(reason = "asked") {
739
+ const held = heldOver;
632
740
  const ending = end(reason);
633
- // Before its first track plays, the player still holds the music from before the stream, which stays.
634
- if (ending?.current) player.stop?.();
741
+ // Before its first track plays, the player still holds the music from before the stream, which
742
+ // stays, unless that is the track of the stream it replaced. A stream replaced by Moondog's next
743
+ // track or stream plays on until that one takes over.
744
+ if (ending && reason !== "replaced" && (ending.current || held)) player.stop?.();
635
745
  return ending ? { tracks_made: ending.made.length } : null;
636
746
  },
637
747
  /** What the room's player reports, which moves the stream along or ends it. */
638
748
  observe(event) {
639
- const current = stream;
640
- // Until its first track plays, what the player reports is about the music before it.
641
- if (!current || !current.current) return;
642
- const track = event?.track;
643
- const ours = (id) => current.made.some((made) => made.id === id);
644
- // A queue or a song that is not one of the stream's tracks is other music, which ends the stream.
645
- if (event?.type === "queue" || event?.type === "playing") {
646
- const items = Array.isArray(event.tracks) ? event.tracks : track ? [track] : [];
647
- if (items.some((item) => !item?.generated || (item.id && !ours(item.id)))) { end("other_music"); return; }
648
- }
649
- const isCurrent = track?.id && track.id === current.current.id;
650
- if (event?.type === "playing" && track?.id && track.id === current.next) {
651
- // The window may already have moved on to it when the time limit had passed. The generator
652
- // has begun counting it, and Moondog's own stop must not read as the listener's skip.
653
- if (expired(current)) { generator.discard?.(track.id); endInTime(); return; }
654
- leftEarly(current);
655
- const made = current.made.find((entry) => entry.id === track.id);
656
- current.current = { id: track.id, heardMs: 0, since: now(), finished: false, seconds: made?.seconds };
657
- if (made) made.started = true;
658
- current.next = null;
659
- current.waiting = false;
660
- prune(current);
661
- void makeNext();
662
- } else if (event?.type === "paused" && isCurrent && current.current.since !== null) {
663
- current.current.heardMs += now() - current.current.since;
664
- current.current.since = null;
665
- } else if (event?.type === "playing" && isCurrent && current.current.since === null) {
666
- current.current.since = now();
667
- } else if (event?.type === "skipped" && isCurrent) {
668
- // A skip past the end of the queue moves on to the next track once it is made.
669
- leftEarly(current);
670
- current.current.finished = true;
671
- if (!current.next) ranDry(current);
672
- else if (expired(current)) endInTime();
673
- } else if ((event?.type === "finished" || event?.type === "failed") && isCurrent) {
674
- // A track that could not play says nothing about the listener; it is passed over like one that ended.
675
- current.current.finished = true;
676
- if (!current.next) ranDry(current);
677
- else if (expired(current)) endInTime();
678
- } else if (event?.type === "stopped" && !current.waiting && !current.starting) {
679
- end("stopped");
749
+ // Other music, or a stop, means the player no longer holds the replaced stream's tracks.
750
+ if (heldOver) {
751
+ const items = Array.isArray(event?.tracks) ? event.tracks : event?.track ? [event.track] : [];
752
+ if (event?.type === "stopped" || ((event?.type === "queue" || event?.type === "playing") && items.some((item) => !heldOver.has(item?.id)))) heldOver = null;
680
753
  }
754
+ move(event);
755
+ changed();
681
756
  },
682
757
  };
683
758
  }
@@ -5249,7 +5249,7 @@ function createToolFactories(
5249
5249
  name: descriptor.tool_name,
5250
5250
  label: descriptor.label,
5251
5251
  description:
5252
- "Start Moondog's stream: instrumental tracks made on this Mac from the listener's taste, one after another, for background listening (work, reading, a long evening). The first plays at once; each next one is made while the last plays, and features a different few of the descriptors, so give a broad set: genres, plus several instruments, textures, moods and rhythms. Descriptors, sources and rules are the same as moondog_make_track. A track the listener skips within 30 seconds steers the stream away from what it featured. seconds is each track's length, 60 to 380, 150 when left out; minutes is how long the stream runs, 10 to 180, 60 when left out. Starting one replaces a stream already running, and making a single track ends it. Use it only when the listener asks for music to keep going in the background; for one track, use moondog_make_track. Say in one or two sentences what shapes it, and that they can stop it any time; the host writes its own line under the reply.",
5252
+ "Start Moondog's stream: instrumental tracks made on this Mac from the listener's taste, one after another, for background listening (work, reading, a long evening). The first plays at once; each next one is made while the last plays, and features a different few of the descriptors, so give a broad set: genres, plus several instruments, textures, moods and rhythms. Descriptors, sources and rules are the same as moondog_make_track. A track the listener skips within 30 seconds steers the stream away from what it featured. seconds is each track's length, 60 to 380, 150 when left out; minutes is how long the stream runs, 10 to 180, 60 when left out. Starting one replaces a stream already running, and making a single track ends it. Use it only when the listener asks for music to keep going in the background; for one track, use moondog_make_track. Say in one or two sentences what shapes it, and that they can stop it any time; the host writes its own line under the reply. If state is not_made with reason stopped_by_listener, the listener turned the stream off while its first track was made: that is their choice, not a failure, so do not start it again in this turn; acknowledge it in a few words.",
5253
5253
  parameters: Type.Object(
5254
5254
  {
5255
5255
  descriptors: Type.Array(descriptorSchema, { minItems: 1, maxItems: DESCRIPTOR_LIMITS.total }),
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { LOCALES, N_, liveScreenTranslator, normalizeLocale } from "../../i18n/index.mjs";
2
+ import { LOCALES, N_, intlLocale, liveScreenTranslator, normalizeLocale, translator } from "../../i18n/index.mjs";
3
3
  import {
4
4
  CombinedAutocompleteProvider,
5
5
  Container,
@@ -126,6 +126,7 @@ const slashCommands = [
126
126
  { name: "web", description: N_("Look things up on public music sites") },
127
127
  { name: "service", description: N_("Choose where Moondog plays music") },
128
128
  { name: "generation", description: N_("Let Moondog make music on this Mac") },
129
+ { name: "onair", description: N_("Go on air: Moondog keeps making music from your taste") },
129
130
  { name: "spotify", description: N_("Connect and control Spotify") },
130
131
  { name: "skip", description: N_("Skip to the next song preview") },
131
132
  { name: "stop", description: N_("Stop the song previews") },
@@ -246,8 +247,10 @@ export async function runMoondogTui({
246
247
  // /skip and /stop exist only where the room controls the previews itself.
247
248
  const previewControls = typeof previewPlayer?.subscribe === "function" &&
248
249
  typeof previewPlayer.skip === "function" && typeof previewPlayer.stop === "function";
250
+ // /onair needs both: the room's own player and music making here.
251
+ const onAirControls = previewControls && typeof generation?.save === "function";
249
252
  const commands = slashCommands.filter(({ name }) => (previewControls || !["skip", "stop"].includes(name)) &&
250
- (name !== "generation" || typeof generation?.save === "function"));
253
+ (name !== "generation" || typeof generation?.save === "function") && (name !== "onair" || onAirControls));
251
254
  const localizedCommands = commands.map(({ name, description }) => ({ name, get description() { return tr.marked(description); } }));
252
255
  let activeRuntime = runtime;
253
256
  application.startNewSession?.("tui_start");
@@ -326,7 +329,20 @@ export async function runMoondogTui({
326
329
  : Math.min(messageQueue.length, 4) + (messageQueue.length > 4 ? 1 : 0);
327
330
  // The preview now playing sits on its own line above the draft.
328
331
  let nowPlaying = null;
329
- const nowPlayingRows = () => (nowPlaying && !profileView && !importView && !firstRunView ? 1 : 0);
332
+ // Moondog's stream as the core shows it: null, or { phase, started_at, ends_at, tracks_made }.
333
+ let onAir = application.streamView?.() ?? null;
334
+ // Between two of the stream's tracks nothing plays, and the line says the next one is being made.
335
+ const dryGap = () => !nowPlaying && onAir?.phase === "waiting";
336
+ const nowPlayingRows = () => ((nowPlaying || dryGap()) && !profileView && !importView && !firstRunView ? 1 : 0);
337
+ // When the stream ends at the latest, as a clock time: an upper bound, so never a countdown. The clock
338
+ // follows the screen's language, as the words around it do.
339
+ const onAirUntil = () => {
340
+ const ends = new Date(onAir?.ends_at);
341
+ let time;
342
+ try { time = new Intl.DateTimeFormat(intlLocale(tr.locale), { hour: "numeric", minute: "2-digit" }).format(ends); }
343
+ catch { time = ends.toTimeString().slice(0, 5); }
344
+ return tr("on air until {time}", { time });
345
+ };
330
346
  let cancelWork = null;
331
347
  let cancellationRequested = false;
332
348
  let workStartedAt = 0;
@@ -481,16 +497,21 @@ export async function runMoondogTui({
481
497
  render(width) {
482
498
  if (profileView || importView || firstRunView) return [];
483
499
  const lines = [];
484
- if (nowPlaying) {
485
- const { track, position, count, type } = nowPlaying;
500
+ if (nowPlaying || dryGap()) {
501
+ const { track, position, count, type } = nowPlaying ?? {};
486
502
  const prefix = ` ${theme.plain ? ">" : "♪"} `;
487
503
  // Where the terminal can't open links, the note in the conversation shows the address.
488
- const apple = getCapabilities().hyperlinks ? hyperlink("Apple Music", track.catalog_url) : "Apple Music";
504
+ const apple = nowPlaying && getCapabilities().hyperlinks ? hyperlink("Apple Music", track.catalog_url) : "Apple Music";
489
505
  const state = type === "loading" ? tr("loading") : type === "paused" ? tr("paused") : "";
506
+ // Once its first track plays, a made track is the stream's, which goes on to the next with /skip.
507
+ const onAirTrack = track?.generated && onAir && onAir.phase !== "starting";
490
508
  // A track Moondog made is its own record, kept on this Mac, with nothing to open elsewhere.
491
- const controls = track.generated ? `${state ? ` ${state}` : ""} · /stop`
509
+ const controls = !nowPlaying ? ` · ${onAirUntil()} · /stop`
510
+ : onAirTrack ? `${state ? ` ${state}` : ""} · ${onAirUntil()} · /skip · /stop`
511
+ : track.generated ? `${state ? ` ${state}` : ""} · /stop`
492
512
  : ` ${position}/${count}${state ? ` · ${state}` : ""} · ${apple} · /skip · /stop`;
493
- const song = sanitizeTerminalText(`${track.title} · ${track.generated ? tr("Made by Moondog") : track.artist_credit}`).replace(/\s+/gu, " ").trim();
513
+ const song = !nowPlaying ? tr("Cutting the next record")
514
+ : sanitizeTerminalText(`${track.title} · ${track.generated ? tr("Made by Moondog") : track.artist_credit}`).replace(/\s+/gu, " ").trim();
494
515
  const room = width - visibleWidth(prefix) - visibleWidth(controls);
495
516
  lines.push(paintBrandLine(room >= 12
496
517
  ? theme.accent(prefix) + theme.text(truncateToWidth(song, room, "…")) + theme.faint(controls)
@@ -722,14 +743,43 @@ export async function runMoondogTui({
722
743
  else if (event?.type === "stopped") nowPlaying = null;
723
744
  tui.requestRender();
724
745
  }) : null;
746
+ // The stream redraws the line above the draft as it moves on. Ending by itself, it goes dark
747
+ // without a word; only a track that couldn't be made says so.
748
+ const stopWatchingStream = application.subscribeStream?.(({ view, ended } = {}) => {
749
+ if (cleanedUp) return;
750
+ onAir = view ?? null;
751
+ if (ended === "failed") setFooter(tr("Off air: the next record couldn't be made."), failure);
752
+ tui.requestRender();
753
+ }) ?? null;
725
754
  // These act at once, even while Moondog is answering.
726
755
  const controlPreviews = (command) => {
727
756
  // /stop also ends Moondog's stream while its next track is still being made and nothing plays.
728
757
  const streamStopped = command === "stop" && application.stopStream?.()?.state === "stopped";
729
758
  const acted = command === "stop" ? previewPlayer.stop() || streamStopped : previewPlayer.skip();
730
- if (!busy) setFooter(!acted ? tr("No previews are playing.") : command === "stop" ? tr("Previews stopped.") : tr("Skipped."));
759
+ const waiting = !acted && command === "skip" && application.streamView?.()?.phase === "waiting";
760
+ // Off air shows even during a reply: a stream stopped while its first track is made has no row to lose.
761
+ if (!busy || streamStopped) {
762
+ setFooter(waiting ? tr("The next record is still being cut.") : !acted ? tr("No previews are playing.")
763
+ : streamStopped ? tr("Off air.") : command === "stop" ? tr("Previews stopped.") : tr("Skipped."));
764
+ }
731
765
  tui.requestRender();
732
766
  };
767
+ // /onair goes off air at once while a stream runs, even while Moondog is answering, and says so
768
+ // when music making is off. Otherwise it returns the request to send, in the reply's language.
769
+ const onAirRequest = () => {
770
+ if (application.streamView?.()) {
771
+ application.stopStream();
772
+ // Said even during a reply: a stream stopped while its first track is made has no line to clear.
773
+ setFooter(tr("Off air."));
774
+ return null;
775
+ }
776
+ // Music making can be turned off, here or in the window, after the room opened.
777
+ if (!application.streamReady?.()) {
778
+ setFooter(tr("Turn on music making first: /generation on."), warning);
779
+ return null;
780
+ }
781
+ return translator(application.locale)("onAir.request");
782
+ };
733
783
 
734
784
  // Making music on this Mac. /generation says where it stands; /generation on [folder] turns it on,
735
785
  // with the folder of Stability AI's MLX build when it is not the usual one; /generation off turns it off.
@@ -780,6 +830,7 @@ export async function runMoondogTui({
780
830
  stopWatchingModel?.();
781
831
  stopWatchingFirstRun?.();
782
832
  stopWatchingPreviews?.();
833
+ stopWatchingStream?.();
783
834
  clearInterval(indicator);
784
835
  indicator = null;
785
836
  clearInterval(homeMotion);
@@ -2358,6 +2409,12 @@ export async function runMoondogTui({
2358
2409
  await openCommandPalette();
2359
2410
  return;
2360
2411
  }
2412
+ // Going on air is a request like any other, which the agent answers by starting the stream.
2413
+ if (command === "onair") {
2414
+ const request = onAirRequest();
2415
+ if (request) await submitListeningTurn(request);
2416
+ return;
2417
+ }
2361
2418
  try {
2362
2419
  if (!["home", "theme", "art", "motion", "model", "service", "resume", "rewind", "branch", "new", "taste", "profile", "import"].includes(command)) enterConversation();
2363
2420
  if (command !== "auth") editor.addToHistory(value);
@@ -2545,9 +2602,11 @@ export async function runMoondogTui({
2545
2602
  controlPreviews(pending.slice(1));
2546
2603
  return;
2547
2604
  }
2548
- if (pending) {
2549
- if (messageQueue.length >= 30) editor.setText(pending);
2550
- else messageQueue.push(pending);
2605
+ // /onair stops a stream at once; the request to go on air waits in the queue like any message.
2606
+ const queued = onAirControls && pending === "/onair" ? onAirRequest() : pending;
2607
+ if (queued) {
2608
+ if (messageQueue.length >= 30) editor.setText(queued);
2609
+ else messageQueue.push(queued);
2551
2610
  if (rewindPending) queuePaused = true;
2552
2611
  }
2553
2612
  if (!cleanedUp) tui.requestRender();