@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 +1 -1
- package/package.json +1 -1
- package/scripts/moondog.mjs +81 -2
- package/src/core/moondog-application.mjs +56 -16
- package/src/i18n/messages/de.mjs +1 -0
- package/src/i18n/messages/en.mjs +2 -0
- package/src/i18n/messages/es.mjs +1 -0
- package/src/i18n/messages/fr.mjs +1 -0
- package/src/i18n/messages/it.mjs +1 -0
- package/src/i18n/messages/ja.mjs +1 -0
- package/src/i18n/messages/ko.mjs +1 -0
- package/src/i18n/messages/pt.mjs +1 -0
- package/src/i18n/messages/zh-Hant.mjs +1 -0
- package/src/i18n/messages/zh.mjs +1 -0
- package/src/i18n/screen/es.mjs +7 -0
- package/src/i18n/screen/ja.mjs +7 -0
- package/src/i18n/screen/pt.mjs +7 -0
- package/src/i18n/screen/zh.mjs +7 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +2 -0
- package/src/integrations/netease/ncm-cli-player.mjs +12 -6
- package/src/integrations/stable-audio/local-generator.mjs +124 -49
- package/src/runtime/pi/agent-runtime.mjs +1 -1
- package/src/surfaces/cli/tui.mjs +71 -12
package/README.md
CHANGED
package/package.json
CHANGED
package/scripts/moondog.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { runListenerProfileCommand, savedProfileActions } from "../src/surfaces/
|
|
|
34
34
|
import { createWebResearch } from "../src/integrations/web/web-research.mjs";
|
|
35
35
|
import { runWebCommand } from "../src/surfaces/cli/web-command.mjs";
|
|
36
36
|
import { openSpotifyConnection } from "../src/integrations/spotify/connection.mjs";
|
|
37
|
+
import { preferredIdleDevice, thisComputerName } from "../src/integrations/spotify/service.mjs";
|
|
37
38
|
import {
|
|
38
39
|
openSpotifyResolutionCache,
|
|
39
40
|
} from "../src/integrations/spotify/resolution-cache.mjs";
|
|
@@ -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
|
|
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"
|
|
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
|
-
|
|
644
|
-
|
|
647
|
+
let started;
|
|
648
|
+
try {
|
|
649
|
+
started = await this.atmosphereStream.start({ compiled, title: compiled.title, seconds, minutes, receipt },
|
|
650
|
+
{ signal, onProgress, onTrack: (track) => this.#noteGeneratedPlay(track), beforePlay: () => this.quietOtherPlayers("apple_music", { signal }) });
|
|
651
|
+
} catch (error) {
|
|
652
|
+
// The listener turned it off while its first track was made: their choice, not a failure to retry.
|
|
653
|
+
if (error?.code === "generation_stopped" && error.ended === "asked" && !signal?.aborted) return { state: "not_made", reason: "stopped_by_listener" };
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
656
|
+
const { made, prompt } = started;
|
|
645
657
|
return { state: "streaming", minutes, track: { id: made.id, title: made.title, seconds: made.duration_seconds, seed: made.seed },
|
|
646
658
|
prompt, shaped_by: compiled.descriptors, sources: tasteSources(compiled.descriptors), left_out: compiled.dropped,
|
|
647
659
|
...(compiled.bpm ? { bpm: compiled.bpm } : {}), elapsed_ms: made.elapsed_ms };
|
|
648
660
|
}
|
|
649
661
|
|
|
650
662
|
// A single track or a new stream takes over from a running stream, once its next track stops being made.
|
|
663
|
+
// The stream's track plays on until the new one starts, so nothing goes silent while that is made.
|
|
664
|
+
// Says whether a stream ended.
|
|
651
665
|
async #endStream(reason) {
|
|
652
|
-
if (!this.atmosphereStream?.active?.()) return;
|
|
666
|
+
if (!this.atmosphereStream?.active?.()) return false;
|
|
653
667
|
this.atmosphereStream.stop(reason);
|
|
654
668
|
for (let waited = 0; this.localGenerator?.busy?.() && waited < 5_000; waited += 100) await delay(100);
|
|
669
|
+
return true;
|
|
655
670
|
}
|
|
656
671
|
|
|
657
672
|
stopStream() {
|
|
@@ -659,6 +674,21 @@ export class MoondogApplication {
|
|
|
659
674
|
return stopped ? { state: "stopped", tracks_made: stopped.tracks_made } : { state: "not_running" };
|
|
660
675
|
}
|
|
661
676
|
|
|
677
|
+
// What the window and the terminal show of the stream: { phase, started_at, ends_at, tracks_made }, or null.
|
|
678
|
+
streamView() {
|
|
679
|
+
return this.atmosphereStream?.view?.() ?? null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Each change of the stream, as { view, ended }; a host without a stream hears nothing.
|
|
683
|
+
subscribeStream(listener) {
|
|
684
|
+
return this.atmosphereStream?.subscribe?.(listener) ?? (() => {});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Whether a Spotify device is this Mac, where its music and Moondog's would play over each other.
|
|
688
|
+
spotifyOnThisMac(device) {
|
|
689
|
+
return spotifyOnThisMac(device);
|
|
690
|
+
}
|
|
691
|
+
|
|
662
692
|
// What the compiler may trace a descriptor to. Each cited finding is read from the saved reading.
|
|
663
693
|
async #tasteContext({ descriptors, moment, request }) {
|
|
664
694
|
const choices = this.domainServices?.listenerChoices?.()?.items ?? [];
|
|
@@ -708,10 +738,13 @@ export class MoondogApplication {
|
|
|
708
738
|
async playNetease({ items, mode, intent } = {}) {
|
|
709
739
|
const player = this.requireNetease();
|
|
710
740
|
this.requireListenerAvoids();
|
|
711
|
-
|
|
741
|
+
const quiet = () => this.quietOtherPlayers("netease");
|
|
742
|
+
if (mode !== "queue") await quiet();
|
|
712
743
|
// A found song lists its artists; a song asked for by name has only the name it was asked by.
|
|
713
|
-
|
|
714
|
-
|
|
744
|
+
// A queue with nothing loaded starts its first song, and quiets the others just before.
|
|
745
|
+
const receipt = await player.play({ items, mode, intent, ...(mode === "queue" ? { beforePlay: quiet } : {}),
|
|
746
|
+
allow: (song) => this.avoidAllows(Array.isArray(song.artists)
|
|
747
|
+
? { title: song.title, artist_credit: song.artists.join(", "), artists: song.artists } : song) });
|
|
715
748
|
if (["playing", "queued"].includes(receipt?.mode)) {
|
|
716
749
|
const songs = receipt.songs ?? [];
|
|
717
750
|
const slots = this.#mixSlots(songs.map((song) => ({ title: song.title, artists: song.artists })));
|
|
@@ -726,8 +759,13 @@ export class MoondogApplication {
|
|
|
726
759
|
|
|
727
760
|
async controlNetease({ action, positionSeconds } = {}) {
|
|
728
761
|
const player = this.requireNetease();
|
|
762
|
+
const quiet = () => this.quietOtherPlayers("netease");
|
|
763
|
+
if (action === "resume") await quiet();
|
|
729
764
|
if (action === "seek") await player.seek({ positionMs: Math.max(0, Number(positionSeconds) || 0) * 1000 });
|
|
730
|
-
|
|
765
|
+
// Next and previous start a song, so the other players go quiet just before; a refusal or a
|
|
766
|
+
// previous that only rewinds starts none.
|
|
767
|
+
else if (action === "next" || action === "previous") await player[action]({ beforePlay: quiet });
|
|
768
|
+
else if (["pause", "resume", "stop"].includes(action)) await player[action]();
|
|
731
769
|
else throw Object.assign(new Error(`Unknown NetEase action: ${action}`), { code: "netease_failed" });
|
|
732
770
|
return await player.nowPlaying();
|
|
733
771
|
}
|
|
@@ -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
|
-
|
|
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);
|
package/src/i18n/messages/de.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `„${title}“ von ${artist}` : artist; return `\n\nZurückgenommen: deine frühere Wahl zu ${name}.`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `Auf diesem Mac mit Stable Audio 3 gemacht: „${title}“ (${length}, Seed ${seed}).`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `Der Stream läuft auf diesem Mac weiter, jedes Mal mit einem neuen Stück, bis zu ${minutes} Minuten. Du kannst ihn jederzeit stoppen.`,
|
|
135
|
+
"onAir.request": "Geh auf Sendung: 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
|
};
|
package/src/i18n/messages/en.mjs
CHANGED
|
@@ -133,6 +133,8 @@ export default {
|
|
|
133
133
|
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" by ${artist}` : artist; return `\n\nUndone: your earlier choice about ${name}.`; },
|
|
134
134
|
"generate.made": ({ title, length, seed }) => `Made on this Mac with Stable Audio 3: "${title}" (${length}, seed ${seed}).`,
|
|
135
135
|
"generate.stream": ({ minutes }) => `The stream keeps going on this Mac, a new track each time, for up to ${minutes} minutes. Stop it any time.`,
|
|
136
|
+
// What the listener says to go on air, in the language the reply follows (/onair in the terminal).
|
|
137
|
+
"onAir.request": "Go on air: 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
|
};
|
package/src/i18n/messages/es.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" de ${artist}` : artist; return `\n\nDeshecho: tu elección anterior sobre ${name}.`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `Hecho en este Mac con Stable Audio 3: «${title}» (${length}, semilla ${seed}).`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `El flujo sigue en este Mac, con una pista nueva cada vez, hasta ${minutes} minutos. Puedes pararlo cuando quieras.`,
|
|
135
|
+
"onAir.request": "Sal al aire: 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
|
};
|
package/src/i18n/messages/fr.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `« ${title} » de ${artist}` : artist; return `\n\nAnnulé : votre choix précédent sur ${name}.`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `Fait sur ce Mac avec Stable Audio 3 : « ${title} » (${length}, graine ${seed}).`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `Le flux continue sur ce Mac, avec un nouveau morceau à chaque fois, jusqu'à ${minutes} minutes. Vous pouvez l'arrêter à tout moment.`,
|
|
135
|
+
"onAir.request": "Prends l'antenne : 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
|
};
|
package/src/i18n/messages/it.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" di ${artist}` : artist; return `\n\nAnnullato: la tua scelta precedente su ${name}.`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `Fatto su questo Mac con Stable Audio 3: «${title}» (${length}, seed ${seed}).`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `Il flusso continua su questo Mac, con un brano nuovo ogni volta, fino a ${minutes} minuti. Puoi fermarlo quando vuoi.`,
|
|
135
|
+
"onAir.request": "Vai in onda: 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
|
};
|
package/src/i18n/messages/ja.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `${artist}の「${title}」` : artist; return `\n\n取り消しました: ${name}についての前の選択。`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `この Mac の Stable Audio 3 で作りました:「${title}」(${length}、シード ${seed})。`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `この Mac で一曲ずつ新しい曲を作り続けます(最長 ${minutes} 分)。いつでも止められます。`,
|
|
135
|
+
"onAir.request": "オンエアして。今の気分に合わせて音楽をかけ続けて",
|
|
135
136
|
"turn.cancelled": "以降の処理をキャンセルしました。",
|
|
136
137
|
"turn.interrupted": "返事が中断されました。記録された Spotify の操作結果は下のとおりです。操作は繰り返していません。",
|
|
137
138
|
};
|
package/src/i18n/messages/ko.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `${artist}의 "${title}"` : artist; return `\n\n취소했습니다: ${name}에 대한 이전 선택.`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `이 Mac의 Stable Audio 3로 만들었습니다: "${title}" (${length}, 시드 ${seed}).`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `이 Mac에서 매번 새 곡을 만들며 최대 ${minutes}분 동안 이어집니다. 언제든 멈출 수 있습니다.`,
|
|
135
|
+
"onAir.request": "방송 시작해 줘. 지금 분위기에 맞춰 음악을 계속 틀어 줘",
|
|
135
136
|
"turn.cancelled": "남은 작업을 취소했습니다.",
|
|
136
137
|
"turn.interrupted": "답변이 중단되었습니다. 기록된 Spotify 결과는 아래와 같으며, 어떤 작업도 다시 실행하지 않았습니다.",
|
|
137
138
|
};
|
package/src/i18n/messages/pt.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" de ${artist}` : artist; return `\n\nDesfeito: sua escolha anterior sobre ${name}.`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `Feito neste Mac com o Stable Audio 3: "${title}" (${length}, semente ${seed}).`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `O fluxo continua neste Mac, com uma faixa nova a cada vez, por até ${minutes} minutos. Pare quando quiser.`,
|
|
135
|
+
"onAir.request": "Entre no ar: 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
|
};
|
package/src/i18n/messages/zh.mjs
CHANGED
|
@@ -132,6 +132,7 @@ export default {
|
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `${artist}的《${title}》` : artist; return `\n\n已撤销:你之前对${name}的选择。`; },
|
|
133
133
|
"generate.made": ({ title, length, seed }) => `用这台 Mac 上的 Stable Audio 3 做的:「${title}」(${length},种子 ${seed})。`,
|
|
134
134
|
"generate.stream": ({ minutes }) => `音乐流会在这台 Mac 上一首接一首地做下去,最多 ${minutes} 分钟,随时可以停。`,
|
|
135
|
+
"onAir.request": "开播吧,照着现在的感觉一直放下去",
|
|
135
136
|
"turn.cancelled": "已取消后续操作。",
|
|
136
137
|
"turn.interrupted": "回复中断;已确认的 Spotify 操作结果如下。未重新执行任何操作。",
|
|
137
138
|
};
|
package/src/i18n/screen/es.mjs
CHANGED
|
@@ -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
|
};
|
package/src/i18n/screen/ja.mjs
CHANGED
|
@@ -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
|
};
|
package/src/i18n/screen/pt.mjs
CHANGED
|
@@ -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
|
};
|
package/src/i18n/screen/zh.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
308
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
634
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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 }),
|
package/src/surfaces/cli/tui.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
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();
|