@audiofool/moondog 0.2.1 → 0.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +12 -0
- package/README.md +3 -2
- package/licenses/stability-ai-community-license.md +58 -0
- package/package.json +3 -1
- package/scripts/moondog.mjs +38 -2
- package/src/core/apple-projection-domain-services.mjs +14 -0
- package/src/core/capability-catalog.mjs +55 -0
- package/src/core/listening-profile-domain-services.mjs +12 -0
- package/src/core/moondog-application.mjs +195 -2
- package/src/i18n/messages/de.mjs +2 -0
- package/src/i18n/messages/en.mjs +2 -0
- package/src/i18n/messages/es.mjs +2 -0
- package/src/i18n/messages/fr.mjs +2 -0
- package/src/i18n/messages/it.mjs +2 -0
- package/src/i18n/messages/ja.mjs +2 -0
- package/src/i18n/messages/ko.mjs +2 -0
- package/src/i18n/messages/pt.mjs +2 -0
- package/src/i18n/messages/zh-Hant.mjs +2 -0
- package/src/i18n/messages/zh.mjs +2 -0
- package/src/i18n/screen/es.mjs +26 -0
- package/src/i18n/screen/ja.mjs +26 -0
- package/src/i18n/screen/pt.mjs +26 -0
- package/src/i18n/screen/zh.mjs +26 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +38 -11
- package/src/integrations/spotify/authentication.mjs +23 -18
- package/src/integrations/stable-audio/local-generator.mjs +683 -0
- package/src/profile/moondog-suggestions.mjs +9 -0
- package/src/profile/taste-compiler.mjs +197 -0
- package/src/runtime/pi/agent-runtime.mjs +278 -9
- package/src/runtime/pi/persistent-credential-store.mjs +6 -28
- package/src/surfaces/cli/command-completions.mjs +1 -0
- package/src/surfaces/cli/format-output.mjs +1 -0
- package/src/surfaces/cli/tui.mjs +70 -8
package/NOTICE
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Moondog
|
|
2
|
+
Copyright 2026 Audiofool.
|
|
3
|
+
|
|
4
|
+
Moondog can make music with Stable Audio 3 by Stability AI.
|
|
5
|
+
Listeners install Stable Audio 3 and its weights on their own Mac; Moondog does not include them.
|
|
6
|
+
|
|
7
|
+
This Stability AI Model is licensed under the Stability AI Community License, Copyright © Stability AI Ltd. All Rights Reserved
|
|
8
|
+
|
|
9
|
+
A copy of the Stability AI Community License is in licenses/stability-ai-community-license.md.
|
|
10
|
+
|
|
11
|
+
Stable Audio 3 uses the T5Gemma text encoder.
|
|
12
|
+
Gemma is provided under and subject to the Gemma Terms of Use found at ai.google.dev/gemma/terms
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h1 align="center">Moondog</h1>
|
|
6
6
|
|
|
7
|
-
<p align="center">
|
|
7
|
+
<p align="center">Source candidate 0.3.0-beta.1</p>
|
|
8
8
|
|
|
9
9
|
<p align="center"><strong>A listening room that shows its work.</strong></p>
|
|
10
10
|
|
|
@@ -33,7 +33,7 @@ npm ci
|
|
|
33
33
|
npm start
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
[@audiofool/moondog](https://www.npmjs.com/package/@audiofool/moondog) 0.2.
|
|
36
|
+
[@audiofool/moondog](https://www.npmjs.com/package/@audiofool/moondog) 0.2.2 is the published package; `npm install --global @audiofool/moondog` installs it.
|
|
37
37
|
To see the agent work through fictional data, with no sign-in at all, run `npm run demo` from the checkout.
|
|
38
38
|
|
|
39
39
|
The source version supports `moondog update` and `/update`, with stable (`latest`) and preview (`beta`) channels.
|
|
@@ -212,3 +212,4 @@ Your profile, and anything Moondog remembers, carry over.
|
|
|
212
212
|
|
|
213
213
|
Moondog is open source under the [Apache License 2.0](LICENSE).
|
|
214
214
|
Copyright 2026 Audiofool.
|
|
215
|
+
Music making runs Stable Audio 3, which you install yourself; [NOTICE](NOTICE) holds its attribution, and [licenses/](licenses/stability-ai-community-license.md) holds the Stability AI Community License.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
STABILITY AI COMMUNITY LICENSE AGREEMENT
|
|
2
|
+
|
|
3
|
+
Last Updated: July 5, 2024
|
|
4
|
+
|
|
5
|
+
1. INTRODUCTION
|
|
6
|
+
|
|
7
|
+
This Agreement applies to any individual person or entity (“You”, “Your” or “Licensee”) that uses or distributes any portion or element of the Stability AI Materials or Derivative Works thereof for any Research & Non-Commercial or Commercial purpose. Capitalized terms not otherwise defined herein are defined in Section V below.
|
|
8
|
+
|
|
9
|
+
This Agreement is intended to allow research, non-commercial, and limited commercial uses of the Models free of charge. In order to ensure that certain limited commercial uses of the Models continue to be allowed, this Agreement preserves free access to the Models for people or organizations generating annual revenue of less than US $1,000,000 (or local currency equivalent).
|
|
10
|
+
|
|
11
|
+
By clicking “I Accept” or by using or distributing or using any portion or element of the Stability Materials or Derivative Works, You agree that You have read, understood and are bound by the terms of this Agreement. If You are acting on behalf of a company, organization or other entity, then “You” includes you and that entity, and You agree that You: (i) are an authorized representative of such entity with the authority to bind such entity to this Agreement, and (ii) You agree to the terms of this Agreement on that entity’s behalf.
|
|
12
|
+
|
|
13
|
+
2. RESEARCH & NON-COMMERCIAL USE LICENSE
|
|
14
|
+
|
|
15
|
+
Subject to the terms of this Agreement, Stability AI grants You a non-exclusive, worldwide, non-transferable, non-sublicensable, revocable and royalty-free limited license under Stability AI’s intellectual property or other rights owned by Stability AI embodied in the Stability AI Materials to use, reproduce, distribute, and create Derivative Works of, and make modifications to, the Stability AI Materials for any Research or Non-Commercial Purpose. “Research Purpose” means academic or scientific advancement, and in each case, is not primarily intended for commercial advantage or monetary compensation to You or others. “Non-Commercial Purpose” means any purpose other than a Research Purpose that is not primarily intended for commercial advantage or monetary compensation to You or others, such as personal use (i.e., hobbyist) or evaluation and testing.
|
|
16
|
+
|
|
17
|
+
3. COMMERCIAL USE LICENSE
|
|
18
|
+
|
|
19
|
+
Subject to the terms of this Agreement (including the remainder of this Section III), Stability AI grants You a non-exclusive, worldwide, non-transferable, non-sublicensable, revocable and royalty-free limited license under Stability AI’s intellectual property or other rights owned by Stability AI embodied in the Stability AI Materials to use, reproduce, distribute, and create Derivative Works of, and make modifications to, the Stability AI Materials for any Commercial Purpose. “Commercial Purpose” means any purpose other than a Research Purpose or Non-Commercial Purpose that is primarily intended for commercial advantage or monetary compensation to You or others, including but not limited to, (i) creating, modifying, or distributing Your product or service, including via a hosted service or application programming interface, and (ii) for Your business’s or organization’s internal operations.
|
|
20
|
+
If You are using or distributing the Stability AI Materials for a Commercial Purpose, You must register with Stability AI at (https://stability.ai/community-license). If at any time You or Your Affiliate(s), either individually or in aggregate, generate more than USD $1,000,000 in annual revenue (or the equivalent thereof in Your local currency), regardless of whether that revenue is generated directly or indirectly from the Stability AI Materials or Derivative Works, any licenses granted to You under this Agreement shall terminate as of such date. You must request a license from Stability AI at (https://stability.ai/enterprise) , which Stability AI may grant to You in its sole discretion. If you receive Stability AI Materials, or any Derivative Works thereof, from a Licensee as part of an integrated end user product, then Section III of this Agreement will not apply to you.
|
|
21
|
+
|
|
22
|
+
4. GENERAL TERMS
|
|
23
|
+
|
|
24
|
+
Your Research, Non-Commercial, and Commercial License(s) under this Agreement are subject to the following terms.
|
|
25
|
+
a. Distribution & Attribution. If You distribute or make available the Stability AI Materials or a Derivative Work to a third party, or a product or service that uses any portion of them, You shall: (i) provide a copy of this Agreement to that third party, (ii) retain the following attribution notice within a "Notice" text file distributed as a part of such copies: "This Stability AI Model is licensed under the Stability AI Community License, Copyright © Stability AI Ltd. All Rights Reserved”, and (iii) prominently display “Powered by Stability AI” on a related website, user interface, blogpost, about page, or product documentation. If You create a Derivative Work, You may add your own attribution notice(s) to the “Notice” text file included with that Derivative Work, provided that You clearly indicate which attributions apply to the Stability AI Materials and state in the “Notice” text file that You changed the Stability AI Materials and how it was modified.
|
|
26
|
+
b. Use Restrictions. Your use of the Stability AI Materials and Derivative Works, including any output or results of the Stability AI Materials or Derivative Works, must comply with applicable laws and regulations (including Trade Control Laws and equivalent regulations) and adhere to the Documentation and Stability AI’s AUP, which is hereby incorporated by reference. Furthermore, You will not use the Stability AI Materials or Derivative Works, or any output or results of the Stability AI Materials or Derivative Works, to create or improve any foundational generative AI model (excluding the Models or Derivative Works).
|
|
27
|
+
c. Intellectual Property.
|
|
28
|
+
(i) Trademark License. No trademark licenses are granted under this Agreement, and in connection with the Stability AI Materials or Derivative Works, You may not use any name or mark owned by or associated with Stability AI or any of its Affiliates, except as required under Section IV(a) herein.
|
|
29
|
+
(ii) Ownership of Derivative Works. As between You and Stability AI, You are the owner of Derivative Works You create, subject to Stability AI’s ownership of the Stability AI Materials and any Derivative Works made by or for Stability AI.
|
|
30
|
+
(iii) Ownership of Outputs. As between You and Stability AI, You own any outputs generated from the Models or Derivative Works to the extent permitted by applicable law.
|
|
31
|
+
(iv) Disputes. If You or Your Affiliate(s) institute litigation or other proceedings against Stability AI (including a cross-claim or counterclaim in a lawsuit) alleging that the Stability AI Materials, Derivative Works or associated outputs or results, or any portion of any of the foregoing, constitutes infringement of intellectual property or other rights owned or licensable by You, then any licenses granted to You under this Agreement shall terminate as of the date such litigation or claim is filed or instituted. You will indemnify and hold harmless Stability AI from and against any claim by any third party arising out of or related to Your use or distribution of the Stability AI Materials or Derivative Works in violation of this Agreement.
|
|
32
|
+
(v) Feedback. From time to time, You may provide Stability AI with verbal and/or written suggestions, comments or other feedback related to Stability AI’s existing or prospective technology, products or services (collectively, “Feedback”). You are not obligated to provide Stability AI with Feedback, but to the extent that You do, You hereby grant Stability AI a perpetual, irrevocable, royalty-free, fully-paid, sub-licensable, transferable, non-exclusive, worldwide right and license to exploit the Feedback in any manner without restriction. Your Feedback is provided “AS IS” and You make no warranties whatsoever about any Feedback.
|
|
33
|
+
d. Disclaimer Of Warranty. UNLESS REQUIRED BY APPLICABLE LAW, THE STABILITY AI MATERIALS AND ANY OUTPUT AND RESULTS THEREFROM ARE PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. YOU ARE SOLELY RESPONSIBLE FOR DETERMINING THE APPROPRIATENESS OR LAWFULNESS OF USING OR REDISTRIBUTING THE STABILITY AI MATERIALS, DERIVATIVE WORKS OR ANY OUTPUT OR RESULTS AND ASSUME ANY RISKS ASSOCIATED WITH YOUR USE OF THE STABILITY AI MATERIALS, DERIVATIVE WORKS AND ANY OUTPUT AND RESULTS.
|
|
34
|
+
e. Limitation Of Liability. IN NO EVENT WILL STABILITY AI OR ITS AFFILIATES BE LIABLE UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, TORT, NEGLIGENCE, PRODUCTS LIABILITY, OR OTHERWISE, ARISING OUT OF THIS AGREEMENT, FOR ANY LOST PROFITS OR ANY DIRECT, INDIRECT, SPECIAL, CONSEQUENTIAL, INCIDENTAL, EXEMPLARY OR PUNITIVE DAMAGES, EVEN IF STABILITY AI OR ITS AFFILIATES HAVE BEEN ADVISED OF THE POSSIBILITY OF ANY OF THE FOREGOING.
|
|
35
|
+
f. Term And Termination. The term of this Agreement will commence upon Your acceptance of this Agreement or access to the Stability AI Materials and will continue in full force and effect until terminated in accordance with the terms and conditions herein. Stability AI may terminate this Agreement if You are in breach of any term or condition of this Agreement. Upon termination of this Agreement, You shall delete and cease use of any Stability AI Materials or Derivative Works. Section IV(d), (e), and (g) shall survive the termination of this Agreement.
|
|
36
|
+
g. Governing Law. This Agreement will be governed by and constructed in accordance with the laws of the United States and the State of California without regard to choice of law principles, and the UN Convention on Contracts for International Sale of Goods does not apply to this Agreement.
|
|
37
|
+
|
|
38
|
+
5. DEFINITIONS
|
|
39
|
+
|
|
40
|
+
“Affiliate(s)” means any entity that directly or indirectly controls, is controlled by, or is under common control with the subject entity; for purposes of this definition, “control” means direct or indirect ownership or control of more than 50% of the voting interests of the subject entity.
|
|
41
|
+
|
|
42
|
+
"Agreement" means this Stability AI Community License Agreement.
|
|
43
|
+
|
|
44
|
+
“AUP” means the Stability AI Acceptable Use Policy available at (https://stability.ai/use-policy), as may be updated from time to time.
|
|
45
|
+
|
|
46
|
+
"Derivative Work(s)” means (a) any derivative work of the Stability AI Materials as recognized by U.S. copyright laws and (b) any modifications to a Model, and any other model created which is based on or derived from the Model or the Model’s output, including “fine tune” and “low-rank adaptation” models derived from a Model or a Model’s output, but do not include the output of any Model.
|
|
47
|
+
|
|
48
|
+
“Documentation” means any specifications, manuals, documentation, and other written information provided by Stability AI related to the Software or Models.
|
|
49
|
+
|
|
50
|
+
“Model(s)" means, collectively, Stability AI’s proprietary models and algorithms, including machine-learning models, trained model weights and other elements of the foregoing listed on Stability’s Core Models Webpage available at (https://stability.ai/core-models), as may be updated from time to time.
|
|
51
|
+
|
|
52
|
+
"Stability AI" or "we" means Stability AI Ltd. and its Affiliates.
|
|
53
|
+
|
|
54
|
+
"Software" means Stability AI’s proprietary software made available under this Agreement now or in the future.
|
|
55
|
+
|
|
56
|
+
“Stability AI Materials” means, collectively, Stability’s proprietary Models, Software and Documentation (and any portion or combination thereof) made available under this Agreement.
|
|
57
|
+
|
|
58
|
+
“Trade Control Laws” means any applicable U.S. and non-U.S. export control and trade sanctions laws and regulations.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@audiofool/moondog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-beta.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local-first personal AI music agent.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"moondog": "scripts/moondog"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
|
+
"NOTICE",
|
|
22
|
+
"licenses/stability-ai-community-license.md",
|
|
21
23
|
"assets/brand/moondog-logo.svg",
|
|
22
24
|
"contracts/v1",
|
|
23
25
|
"contracts/v2",
|
package/scripts/moondog.mjs
CHANGED
|
@@ -53,6 +53,7 @@ import { startListeningUpkeep } from "../src/core/listening-upkeep.mjs";
|
|
|
53
53
|
import { createAppleMusicCatalog } from "../src/integrations/apple-music/catalog.mjs";
|
|
54
54
|
import { createTerminalPreviewPlayer } from "../src/integrations/apple-music/terminal-preview-player.mjs";
|
|
55
55
|
import { createNeteasePlayer } from "../src/integrations/netease/ncm-cli-player.mjs";
|
|
56
|
+
import { createAtmosphereStream, createLocalGenerator } from "../src/integrations/stable-audio/local-generator.mjs";
|
|
56
57
|
import { createMusicAppPlayer } from "../src/integrations/apple-music/music-app.mjs";
|
|
57
58
|
import {
|
|
58
59
|
createOpenMusicSimilarity,
|
|
@@ -850,11 +851,29 @@ async function main() {
|
|
|
850
851
|
const neteasePlayer = createNeteasePlayer();
|
|
851
852
|
const playbackService = await resolvePlaybackService();
|
|
852
853
|
if (playbackService === "netease") await neteasePlayer.check();
|
|
854
|
+
// Moondog makes music on this Mac with the listener's own Stable Audio 3 once they turn it on, in a
|
|
855
|
+
// room that can play what it makes. The terminal and the window each keep one generator, so each
|
|
856
|
+
// makes one track at a time; a choice saved on one side is checked again on the other.
|
|
857
|
+
const localGenerator = previewPlayer ? createLocalGenerator() : null;
|
|
858
|
+
await localGenerator?.check();
|
|
859
|
+
void localGenerator?.sweep().catch(() => {});
|
|
860
|
+
const unwatchGeneration = localGenerator?.watch() ?? (() => {});
|
|
861
|
+
// How each made track landed (played through, skipped, stopped) comes from this room's player.
|
|
862
|
+
await localGenerator?.load();
|
|
863
|
+
const unobservePlayer = localGenerator ? previewPlayer.subscribe?.((event) => localGenerator.observe(event)) : null;
|
|
864
|
+
// Moondog's atmosphere stream: one per room, on its generator and its player, moved along by what the player reports.
|
|
865
|
+
const atmosphereStream = localGenerator && typeof previewPlayer?.enqueueGenerated === "function"
|
|
866
|
+
? createAtmosphereStream({ generator: localGenerator, player: previewPlayer }) : null;
|
|
867
|
+
// After the generator's own observer: the stream may stop the player while an event is still being
|
|
868
|
+
// delivered, and the generator must record that event's outcome first.
|
|
869
|
+
const unobserveStream = atmosphereStream ? previewPlayer.subscribe?.((event) => atmosphereStream.observe(event)) : null;
|
|
870
|
+
// Music making turned off, here or on the other surface, ends the stream.
|
|
871
|
+
const unwatchStreamGeneration = atmosphereStream ? localGenerator.subscribe((status) => { if (status.state !== "ready") atmosphereStream.stop("generation_off"); }) : null;
|
|
853
872
|
const playbackSelection = createPlaybackSelection({ service: playbackService, save: writePlaybackPreference });
|
|
854
873
|
// The terminal and the window each run this core on one playback.json. A service chosen on the
|
|
855
874
|
// other side reaches this side's picker and agents, and stops the previews this side plays.
|
|
856
875
|
const unwatchPlayback = watchPlaybackPreference(playbackSelection, {
|
|
857
|
-
onChange: (service) => { if (service !== "apple_music") previewPlayer?.stop?.(); },
|
|
876
|
+
onChange: (service) => { if (service !== "apple_music") { atmosphereStream?.stop("service_change"); previewPlayer?.stop?.(); } },
|
|
858
877
|
});
|
|
859
878
|
// The main application and every conversation the window opens beside it are built from
|
|
860
879
|
// this one list, so they cannot drift apart. Each passes in what may change after launch:
|
|
@@ -863,7 +882,7 @@ async function main() {
|
|
|
863
882
|
const spotifyTopArtists = { read_at: null, artists: [] };
|
|
864
883
|
const applicationOptions = ({ domainServices, domainServicesError, spotifyConnection, locale, playbackService }) => ({
|
|
865
884
|
domainServices, domainServicesError, spotifyConnection, locale, playbackService, playbackSelection,
|
|
866
|
-
webResearch, memoryStore, musicCatalog, musicPreviewPlayer: previewPlayer, neteasePlayer, musicSimilarity, artistIdentityResolver,
|
|
885
|
+
webResearch, memoryStore, musicCatalog, musicPreviewPlayer: previewPlayer, neteasePlayer, localGenerator, atmosphereStream, musicSimilarity, artistIdentityResolver,
|
|
867
886
|
spotifyTopArtists, musicApp,
|
|
868
887
|
});
|
|
869
888
|
const application = new MoondogApplication(applicationOptions({
|
|
@@ -1059,6 +1078,13 @@ async function main() {
|
|
|
1059
1078
|
return prepareTuiHistoryImport(undefined, { recentPage, librarySnapshot, libraryUnavailable });
|
|
1060
1079
|
},
|
|
1061
1080
|
openImportHelp,
|
|
1081
|
+
// Local generation: what the room shows and the switch the listener turns, in Settings or with /generation.
|
|
1082
|
+
generation: localGenerator && {
|
|
1083
|
+
status: () => localGenerator.status(),
|
|
1084
|
+
check: () => localGenerator.check(),
|
|
1085
|
+
save: (settings) => localGenerator.save(settings),
|
|
1086
|
+
subscribe: (listener) => localGenerator.subscribe(listener),
|
|
1087
|
+
},
|
|
1062
1088
|
// The room asks whether a Last.fm key is set, and saves one the listener pastes.
|
|
1063
1089
|
lastfm: {
|
|
1064
1090
|
status: async () => {
|
|
@@ -1165,6 +1191,16 @@ async function main() {
|
|
|
1165
1191
|
});
|
|
1166
1192
|
} finally {
|
|
1167
1193
|
unwatchPlayback();
|
|
1194
|
+
unwatchGeneration();
|
|
1195
|
+
// A made track still playing is stopped while the generator listens, so its outcome is kept.
|
|
1196
|
+
if (localGenerator) {
|
|
1197
|
+
previewPlayer?.stop?.();
|
|
1198
|
+
await localGenerator.flush?.();
|
|
1199
|
+
}
|
|
1200
|
+
unobservePlayer?.();
|
|
1201
|
+
unobserveStream?.();
|
|
1202
|
+
unwatchStreamGeneration?.();
|
|
1203
|
+
atmosphereStream?.stop("closed");
|
|
1168
1204
|
listeningUpkeep?.stop();
|
|
1169
1205
|
lyrics?.close();
|
|
1170
1206
|
// Conversations close first: their domain services are forks of the main application's.
|
|
@@ -4,6 +4,7 @@ import { createListenerProfileInput, savedListenerChoices } from "../profile/lis
|
|
|
4
4
|
import { lyricSeedsFromProfile } from "./lyric-profile.mjs";
|
|
5
5
|
import { cachedAvoidGate, createAvoidGate, listeningVersion } from "./listener-avoids.mjs";
|
|
6
6
|
import { recordListenerChoice } from "../profile/listener-corrections.mjs";
|
|
7
|
+
import { listenedNames } from "../profile/taste-compiler.mjs";
|
|
7
8
|
import { normalizedTrackTitle } from "./track-title.mjs";
|
|
8
9
|
import { collectionCoverage } from "../profile/music-providers.mjs";
|
|
9
10
|
import { lstat } from "node:fs/promises";
|
|
@@ -1848,6 +1849,19 @@ export class AppleProjectionDomainServices {
|
|
|
1848
1849
|
return (this.#listeningHistoryStore?.listenedTracks?.({ subjectId: this.#subjectId }) ?? []).some(heard => exactTitleArtistMatch(track, heard));
|
|
1849
1850
|
}
|
|
1850
1851
|
|
|
1852
|
+
// Every Avoid in force, imported bans included, as the play paths read them.
|
|
1853
|
+
activeAvoids() {
|
|
1854
|
+
const store = this.#listeningHistoryStore;
|
|
1855
|
+
const avoids = store?.activeAvoidances?.({ subjectId: this.#subjectId }) ?? [];
|
|
1856
|
+
return normalizeAppleCorrectionLabels({ listener_assertions: { avoids } }, this.#projection).listener_assertions.avoids
|
|
1857
|
+
.map((avoid) => ({ stance: "avoid", subject: avoid.entity_type ?? "artist", name: avoid.label, ...(avoid.artist_credit ? { artist: avoid.artist_credit } : {}) }));
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// Every artist, song and album the listener has played, so a track Moondog makes names none of them.
|
|
1861
|
+
listenedNames() {
|
|
1862
|
+
return listenedNames(this.#listeningHistoryStore?.listenedTracks?.({ subjectId: this.#subjectId }) ?? []);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1851
1865
|
registerExternalCandidateSet({ tracks, source, excludeKnown = true } = {}) {
|
|
1852
1866
|
if (
|
|
1853
1867
|
!Array.isArray(tracks) ||
|
|
@@ -601,6 +601,55 @@ const catalog = [
|
|
|
601
601
|
label: N_("Save the playlist to Spotify"),
|
|
602
602
|
},
|
|
603
603
|
},
|
|
604
|
+
{
|
|
605
|
+
id: "music.generate.local",
|
|
606
|
+
version: "1",
|
|
607
|
+
state: "enabled",
|
|
608
|
+
effect: "generate_local",
|
|
609
|
+
description:
|
|
610
|
+
"Make an instrumental track on this Mac with the listener's own Stable Audio 3 install, shaped by their taste, and play it in Moondog's player. Stable Audio 3 runs offline; the track stays on this Mac.",
|
|
611
|
+
requires_local_generation: true,
|
|
612
|
+
agent_tool: {
|
|
613
|
+
name: "moondog_make_track",
|
|
614
|
+
label: N_("Make a track"),
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
id: "music.generate.replay",
|
|
619
|
+
version: "1",
|
|
620
|
+
state: "enabled",
|
|
621
|
+
effect: "generate_local",
|
|
622
|
+
description: "Play again a track Moondog made on this Mac, from its own folder.",
|
|
623
|
+
requires_local_generation: true,
|
|
624
|
+
agent_tool: {
|
|
625
|
+
name: "moondog_play_made_track",
|
|
626
|
+
label: N_("Play a track Moondog made"),
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
id: "music.generate.stream",
|
|
631
|
+
version: "1",
|
|
632
|
+
state: "enabled",
|
|
633
|
+
effect: "generate_local",
|
|
634
|
+
description: "Keep making instrumental tracks on this Mac from the listener's taste, one after another, until they stop it or the time runs out.",
|
|
635
|
+
requires_local_generation: true,
|
|
636
|
+
agent_tool: {
|
|
637
|
+
name: "moondog_start_stream",
|
|
638
|
+
label: N_("Start a stream"),
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
id: "music.generate.stream.stop",
|
|
643
|
+
version: "1",
|
|
644
|
+
state: "enabled",
|
|
645
|
+
effect: "generate_local",
|
|
646
|
+
description: "Stop Moondog's stream of tracks made on this Mac.",
|
|
647
|
+
requires_local_generation: true,
|
|
648
|
+
agent_tool: {
|
|
649
|
+
name: "moondog_stop_stream",
|
|
650
|
+
label: N_("Stop the stream"),
|
|
651
|
+
},
|
|
652
|
+
},
|
|
604
653
|
{
|
|
605
654
|
id: "music.generate",
|
|
606
655
|
version: "1",
|
|
@@ -636,7 +685,11 @@ function withRuntimeAvailability(
|
|
|
636
685
|
profileBuildReady,
|
|
637
686
|
musicPreviewReady,
|
|
638
687
|
tasteChoicesReady,
|
|
688
|
+
localGenerationReady,
|
|
639
689
|
) {
|
|
690
|
+
if (capability.requires_local_generation && !localGenerationReady) {
|
|
691
|
+
return { ...capability, state: "blocked", blocked_by: "local_generation_not_ready" };
|
|
692
|
+
}
|
|
640
693
|
if (capability.requires_taste_choices && !tasteChoicesReady) {
|
|
641
694
|
return { ...capability, state: "blocked", blocked_by: "taste_choices_not_ready" };
|
|
642
695
|
}
|
|
@@ -769,6 +822,7 @@ export function listCapabilities({
|
|
|
769
822
|
profileBuildReady = false,
|
|
770
823
|
musicPreviewReady = false,
|
|
771
824
|
tasteChoicesReady = false,
|
|
825
|
+
localGenerationReady = false,
|
|
772
826
|
} = {}) {
|
|
773
827
|
return structuredClone(
|
|
774
828
|
catalog.map((capability) =>
|
|
@@ -791,6 +845,7 @@ export function listCapabilities({
|
|
|
791
845
|
profileBuildReady,
|
|
792
846
|
musicPreviewReady,
|
|
793
847
|
tasteChoicesReady,
|
|
848
|
+
localGenerationReady,
|
|
794
849
|
),
|
|
795
850
|
),
|
|
796
851
|
);
|
|
@@ -4,6 +4,7 @@ import { createListenerProfileInput, savedListenerChoices } from "../profile/lis
|
|
|
4
4
|
import { lyricSeedsFromProfile } from "./lyric-profile.mjs";
|
|
5
5
|
import { cachedAvoidGate, listeningVersion } from "./listener-avoids.mjs";
|
|
6
6
|
import { recordListenerChoice } from "../profile/listener-corrections.mjs";
|
|
7
|
+
import { listenedNames } from "../profile/taste-compiler.mjs";
|
|
7
8
|
|
|
8
9
|
import { isUuid } from "./uuid-v5.mjs";
|
|
9
10
|
import { createListeningHistoryProfileProjection } from "../profile/spotify-archive-taste.mjs";
|
|
@@ -601,6 +602,17 @@ export class ListeningProfileDomainServices {
|
|
|
601
602
|
return (this.#store.listenedTracks?.({ subjectId: this.#subjectId }) ?? []).some(heard => exactTitleArtistMatch(track, heard));
|
|
602
603
|
}
|
|
603
604
|
|
|
605
|
+
// Every Avoid in force, imported bans included, as the play paths read them.
|
|
606
|
+
activeAvoids() {
|
|
607
|
+
return (this.#store.activeAvoidances?.({ subjectId: this.#subjectId }) ?? [])
|
|
608
|
+
.map((avoid) => ({ stance: "avoid", subject: avoid.entity_type ?? "artist", name: avoid.label, ...(avoid.artist_credit ? { artist: avoid.artist_credit } : {}) }));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Every artist, song and album the listener has played, so a track Moondog makes names none of them.
|
|
612
|
+
listenedNames() {
|
|
613
|
+
return listenedNames(this.#store.listenedTracks?.({ subjectId: this.#subjectId }) ?? []);
|
|
614
|
+
}
|
|
615
|
+
|
|
604
616
|
registerExternalCandidateSet({ tracks, source, excludeKnown = true } = {}) {
|
|
605
617
|
if (!Array.isArray(tracks) || tracks.length < 1 || tracks.length > HISTORY_MAX_ITEMS) {
|
|
606
618
|
throw new TypeError("External catalog candidates are invalid");
|
|
@@ -5,6 +5,8 @@ import { compactListenerProfile, readListenerProfile } from "../profile/listener
|
|
|
5
5
|
import { mixCounts, songKeys } from "../profile/moondog-suggestions.mjs";
|
|
6
6
|
import { ARTIST_CREDIT_SEPARATORS } from "./listener-avoids.mjs";
|
|
7
7
|
import { textNamesSong } from "./track-title.mjs";
|
|
8
|
+
import { compileTaste, tasteSources } from "../profile/taste-compiler.mjs";
|
|
9
|
+
import { stableAudioPrompt } from "../integrations/stable-audio/local-generator.mjs";
|
|
8
10
|
|
|
9
11
|
import {
|
|
10
12
|
readAppleMusicSourceStatus,
|
|
@@ -221,6 +223,8 @@ export class MoondogApplication {
|
|
|
221
223
|
musicSimilarity = null,
|
|
222
224
|
musicPreviewPlayer = null,
|
|
223
225
|
neteasePlayer = null,
|
|
226
|
+
localGenerator = null,
|
|
227
|
+
atmosphereStream = null,
|
|
224
228
|
musicApp = null,
|
|
225
229
|
agentHostNotes = [],
|
|
226
230
|
artistIdentityResolver = null,
|
|
@@ -266,6 +270,10 @@ export class MoondogApplication {
|
|
|
266
270
|
this.musicPreviewPlayer = musicPreviewPlayer;
|
|
267
271
|
// NetEase Cloud Music through ncm-cli (src/integrations/netease), shared by every conversation.
|
|
268
272
|
this.neteasePlayer = neteasePlayer;
|
|
273
|
+
// Stable Audio 3 on this Mac (src/integrations/stable-audio), shared by every conversation, so one track is made at a time.
|
|
274
|
+
this.localGenerator = localGenerator;
|
|
275
|
+
// Moondog's atmosphere stream on that generator, one per room (src/integrations/stable-audio).
|
|
276
|
+
this.atmosphereStream = atmosphereStream;
|
|
269
277
|
// The Music app on this Mac (src/integrations/apple-music/music-app.mjs), read for what the
|
|
270
278
|
// listener plays there themselves.
|
|
271
279
|
this.musicApp = musicApp;
|
|
@@ -523,6 +531,174 @@ export class MoondogApplication {
|
|
|
523
531
|
return this.neteasePlayer;
|
|
524
532
|
}
|
|
525
533
|
|
|
534
|
+
// Moondog makes music on this Mac once the listener turned it on and the install is ready, in a
|
|
535
|
+
// host whose player can play a local track: the terminal room and the window, not one-shot commands.
|
|
536
|
+
localGenerationReady() {
|
|
537
|
+
return this.localGenerator?.ready?.() === true && typeof this.musicPreviewPlayer?.playGenerated === "function";
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
generationStatus() {
|
|
541
|
+
return this.localGenerator?.status?.() ?? null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Moondog's newest tracks and how each landed, for the agent to calibrate the next one by. They
|
|
545
|
+
// are outcomes of Moondog's own picks, never the listener's taste.
|
|
546
|
+
// Each reply reads the made tracks again, so the other surface's tracks and outcomes show too.
|
|
547
|
+
async refreshMadeTracks() {
|
|
548
|
+
if (this.localGenerationReady()) await this.localGenerator.load?.();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
madeTracks() {
|
|
552
|
+
if (!this.localGenerationReady()) return null;
|
|
553
|
+
const tracks = this.localGenerator.recentTracks?.(5) ?? [];
|
|
554
|
+
return tracks.length ? tracks : null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Plays a track Moondog made again. A track the listener named in their own words is their pick:
|
|
558
|
+
// it stays out of the ledger and counts as their replay. Otherwise it is Moondog's pick again.
|
|
559
|
+
async playMadeTrack({ id } = {}, { request = null, signal } = {}) {
|
|
560
|
+
if (!this.localGenerationReady()) throw Object.assign(new Error("Local generation is not ready. The listener can turn it on in Settings or with /generation."), { code: "generation_not_ready" });
|
|
561
|
+
const receipt = await this.localGenerator.receipt(id);
|
|
562
|
+
if (!receipt) throw Object.assign(new Error("There is no track Moondog made with that id on this Mac."), { code: "made_track_not_found" });
|
|
563
|
+
const file = await this.localGenerator.trackFile(id);
|
|
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
|
+
// Playing one of Moondog's tracks again ends its stream, on every surface.
|
|
566
|
+
await this.#endStream("replaced");
|
|
567
|
+
const title = String(receipt.title ?? "");
|
|
568
|
+
// Named means quoted, or the title with Moondog, as on every play path: an open request shares
|
|
569
|
+
// words with the plain titles Moondog gives its records ("something for a rainy day").
|
|
570
|
+
const named = typeof request === "string" && textNamesSong(request, title, ["Moondog"]);
|
|
571
|
+
await this.quietOtherPlayers("apple_music", { signal });
|
|
572
|
+
// A reply stopped before the track starts plays nothing, and no replay is recorded.
|
|
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.
|
|
575
|
+
this.localGenerator.restart?.(id);
|
|
576
|
+
this.musicPreviewPlayer.playGenerated({ id, title, seconds: receipt.audio?.duration_seconds, file });
|
|
577
|
+
if (!named) this.#noteGeneratedPlay({ id, title });
|
|
578
|
+
await this.localGenerator.recordReplay?.(id, named ? "listener" : "moondog");
|
|
579
|
+
return { state: "playing", track: { id, title, seconds: receipt.audio?.duration_seconds ?? null }, chosen_by: named ? "listener" : "moondog" };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Makes one instrumental track from the listener's taste and plays it in Moondog's own player.
|
|
583
|
+
// The agent proposes sound descriptors, each with its source; the taste compiler keeps only the
|
|
584
|
+
// ones it can trace and none that names an artist or a song. The track and its receipt stay on
|
|
585
|
+
// this Mac, and its play goes in the ledger as Moondog's own, never as the listener's attention.
|
|
586
|
+
// moment and request come from the agent's host, not from the model.
|
|
587
|
+
async makeTrack({ descriptors, bpm, bpmSource, seconds, title } = {}, { moment = null, request = null, transient = false, signal, onProgress } = {}) {
|
|
588
|
+
const { compiled, receipt } = await this.#compileForGeneration({ descriptors, bpm, bpmSource, title }, { moment, request, transient });
|
|
589
|
+
if (!compiled.usable) {
|
|
590
|
+
return { state: "not_made", reason: compiled.descriptors.length ? "needs_a_sound" : "no_traceable_descriptors", dropped: compiled.dropped };
|
|
591
|
+
}
|
|
592
|
+
const prompt = stableAudioPrompt(compiled);
|
|
593
|
+
await this.#endStream("replaced");
|
|
594
|
+
const made = await this.localGenerator.generate({ prompt, seconds, title: compiled.title, receipt }, { signal, onProgress });
|
|
595
|
+
// A reply stopped as the track finished keeps the track, but plays nothing over what is on now.
|
|
596
|
+
await this.quietOtherPlayers("apple_music", { signal });
|
|
597
|
+
// The terminal plays the file itself; the window asks for it by id and never sees the path.
|
|
598
|
+
const file = await this.localGenerator.trackFile(made.id);
|
|
599
|
+
// A reply stopped while the track was made, or while the other players paused, keeps the track but plays nothing.
|
|
600
|
+
if (signal?.aborted) throw Object.assign(new Error("The track was made but not played, because the reply was stopped."), { code: "generation_stopped" });
|
|
601
|
+
this.musicPreviewPlayer.playGenerated({ id: made.id, title: made.title, seconds: made.duration_seconds, file });
|
|
602
|
+
this.#noteGeneratedPlay(made);
|
|
603
|
+
return { state: "playing", track: { id: made.id, title: made.title, seconds: made.duration_seconds, seed: made.seed },
|
|
604
|
+
prompt, shaped_by: compiled.descriptors, sources: tasteSources(compiled.descriptors), left_out: compiled.dropped,
|
|
605
|
+
...(compiled.bpm ? { bpm: compiled.bpm } : {}), elapsed_ms: made.elapsed_ms };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// The taste compiled for a track or a stream, and what its receipts keep of why.
|
|
609
|
+
async #compileForGeneration({ descriptors, bpm, bpmSource, title }, { moment, request, transient }) {
|
|
610
|
+
if (!this.localGenerationReady()) throw Object.assign(new Error("Local generation is not ready. The listener can turn it on in Settings or with /generation."), { code: "generation_not_ready" });
|
|
611
|
+
this.requireListenerAvoids();
|
|
612
|
+
const context = await this.#tasteContext({ descriptors, moment, request });
|
|
613
|
+
const compiled = compileTaste({ descriptors, bpm, bpm_source: bpmSource, title }, context);
|
|
614
|
+
return { compiled, receipt: {
|
|
615
|
+
shaped_by: compiled.descriptors, left_out: compiled.dropped,
|
|
616
|
+
...(compiled.bpm ? { bpm: compiled.bpm, bpm_source: compiled.bpm_source } : {}),
|
|
617
|
+
// A conversation that read Spotify stays out of memory, and its words out of the receipt too.
|
|
618
|
+
...(typeof request === "string" && !transient && !this.transientSpotifyContext ? { request: Array.from(request).slice(0, 300).join("") } : {}),
|
|
619
|
+
avoids_in_force: context.avoids.length,
|
|
620
|
+
} };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// A stream needs a player that can queue a made track behind the one playing.
|
|
624
|
+
streamReady() {
|
|
625
|
+
return this.localGenerationReady() && typeof this.atmosphereStream?.start === "function" &&
|
|
626
|
+
typeof this.musicPreviewPlayer?.enqueueGenerated === "function";
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
streamStatus() {
|
|
630
|
+
return this.atmosphereStream?.status?.() ?? null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Starts Moondog's atmosphere stream from the listener's taste: its first track plays now, and
|
|
634
|
+
// each next one is made while the last plays. Every track goes in the ledger as Moondog's pick.
|
|
635
|
+
async startStream({ descriptors, bpm, bpmSource, seconds, minutes, title } = {}, { moment = null, request = null, transient = false, signal, onProgress } = {}) {
|
|
636
|
+
if (!this.streamReady()) throw Object.assign(new Error("A stream can't run here. Music making must be on, in the terminal room or the window."), { code: "stream_not_ready" });
|
|
637
|
+
const { compiled, receipt } = await this.#compileForGeneration({ descriptors, bpm, bpmSource, title }, { moment, request, transient });
|
|
638
|
+
if (!compiled.usable) {
|
|
639
|
+
return { state: "not_made", reason: compiled.descriptors.length ? "needs_a_sound" : "no_traceable_descriptors", dropped: compiled.dropped };
|
|
640
|
+
}
|
|
641
|
+
await this.#endStream("replaced");
|
|
642
|
+
// 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 }) });
|
|
645
|
+
return { state: "streaming", minutes, track: { id: made.id, title: made.title, seconds: made.duration_seconds, seed: made.seed },
|
|
646
|
+
prompt, shaped_by: compiled.descriptors, sources: tasteSources(compiled.descriptors), left_out: compiled.dropped,
|
|
647
|
+
...(compiled.bpm ? { bpm: compiled.bpm } : {}), elapsed_ms: made.elapsed_ms };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// A single track or a new stream takes over from a running stream, once its next track stops being made.
|
|
651
|
+
async #endStream(reason) {
|
|
652
|
+
if (!this.atmosphereStream?.active?.()) return;
|
|
653
|
+
this.atmosphereStream.stop(reason);
|
|
654
|
+
for (let waited = 0; this.localGenerator?.busy?.() && waited < 5_000; waited += 100) await delay(100);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
stopStream() {
|
|
658
|
+
const stopped = this.atmosphereStream?.stop?.("asked") ?? null;
|
|
659
|
+
return stopped ? { state: "stopped", tracks_made: stopped.tracks_made } : { state: "not_running" };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// What the compiler may trace a descriptor to. Each cited finding is read from the saved reading.
|
|
663
|
+
async #tasteContext({ descriptors, moment, request }) {
|
|
664
|
+
const choices = this.domainServices?.listenerChoices?.()?.items ?? [];
|
|
665
|
+
// Every Avoid in force, not only the newest choices, so an old one still keeps its artist out.
|
|
666
|
+
let avoids = null;
|
|
667
|
+
try { avoids = this.domainServices?.activeAvoids?.() ?? null; } catch { /* The newest choices still hold. */ }
|
|
668
|
+
const recent = this.domainServices?.recentAttention?.() ?? null;
|
|
669
|
+
const cited = [...new Set((Array.isArray(descriptors) ? descriptors : [])
|
|
670
|
+
.filter((descriptor) => descriptor?.source === "reading" && typeof descriptor.from === "string").map((descriptor) => descriptor.from.trim()))].slice(0, 12);
|
|
671
|
+
const claims = new Map();
|
|
672
|
+
let revisionId = null;
|
|
673
|
+
for (const claimId of cited) {
|
|
674
|
+
try {
|
|
675
|
+
const reading = await this.getListenerProfile({ claimId });
|
|
676
|
+
if (reading?.claim) { claims.set(claimId, reading.claim); revisionId ??= reading.revision_id ?? null; }
|
|
677
|
+
} catch { /* Not a finding of the saved reading; the compiler drops it. */ }
|
|
678
|
+
}
|
|
679
|
+
let listened = null;
|
|
680
|
+
try { listened = this.domainServices?.listenedNames?.() ?? null; } catch { /* Without a history, only choices are checked. */ }
|
|
681
|
+
return {
|
|
682
|
+
claim: (id) => claims.get(id) ?? null, revisionId,
|
|
683
|
+
likes: choices.filter((choice) => choice.stance === "like"),
|
|
684
|
+
avoids: Array.isArray(avoids) ? avoids : choices.filter((choice) => choice.stance === "avoid"),
|
|
685
|
+
recentArtists: recent?.top_artists ?? [],
|
|
686
|
+
...(recent?.from_date ? { recentWindow: { from_date: recent.from_date, through_date: recent.through_date } } : {}),
|
|
687
|
+
historyArtists: listened?.artists ?? [], historyTitles: listened?.titles ?? [],
|
|
688
|
+
avoidsName: (name) => !this.avoidAllows({ artist_credit: name, artists: [name] }),
|
|
689
|
+
moment, request,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// A track Moondog made and played is its own pick by definition, so no listener-named check applies.
|
|
694
|
+
#noteGeneratedPlay(made) {
|
|
695
|
+
if (typeof this.domainServices?.recordMoondogSuggestions !== "function") return;
|
|
696
|
+
try {
|
|
697
|
+
this.domainServices.recordMoondogSuggestions([{ service: "moondog", action: "generated.play", providerTrackId: `moondog:generated:${made.id}`,
|
|
698
|
+
title: made.title, artistCredit: "", requestedAt: new Date(this.spotifyContextClock()).toISOString() }]);
|
|
699
|
+
} catch { /* The track still plays; only its origin goes unrecorded. The receipt stays. */ }
|
|
700
|
+
}
|
|
701
|
+
|
|
526
702
|
async searchNetease({ query, limit, intent } = {}) {
|
|
527
703
|
const songs = await this.requireNetease().search({ query, limit, intent });
|
|
528
704
|
return Array.isArray(songs) ? this.#withSuggestedBefore(songs, song => ({ service: "netease",
|
|
@@ -645,7 +821,10 @@ export class MoondogApplication {
|
|
|
645
821
|
if (starting !== "netease" && this.neteaseReady() && (await this.neteasePlayer.nowPlaying()).state === "playing") await this.neteasePlayer.pause();
|
|
646
822
|
})(),
|
|
647
823
|
(async () => {
|
|
648
|
-
if (starting
|
|
824
|
+
if (starting === "apple_music") return;
|
|
825
|
+
// Other music ends Moondog's stream too, even while its next track is still being made.
|
|
826
|
+
this.atmosphereStream?.stop?.("other_music");
|
|
827
|
+
this.musicPreviewPlayer?.stop?.();
|
|
649
828
|
})(),
|
|
650
829
|
]);
|
|
651
830
|
}
|
|
@@ -654,6 +833,7 @@ export class MoondogApplication {
|
|
|
654
833
|
async #stopPlaybackService(service) {
|
|
655
834
|
try {
|
|
656
835
|
if (service === "apple_music") {
|
|
836
|
+
this.atmosphereStream?.stop?.("service_change");
|
|
657
837
|
this.musicPreviewPlayer?.stop?.();
|
|
658
838
|
// The listener's own Music app too, when it plays.
|
|
659
839
|
if ((await this.musicApp?.nowPlaying?.())?.state === "playing") await this.musicApp.pause();
|
|
@@ -706,6 +886,9 @@ export class MoondogApplication {
|
|
|
706
886
|
}
|
|
707
887
|
if (avoided) found.skipped_avoided_count = avoided;
|
|
708
888
|
if (found.played.length) {
|
|
889
|
+
// Songs Moondog plays end its stream, even one whose first track is still being made; the
|
|
890
|
+
// stream's own tracks also start through quietOtherPlayers("apple_music"), so it can't do this.
|
|
891
|
+
this.atmosphereStream?.stop?.("other_music");
|
|
709
892
|
await this.quietOtherPlayers("apple_music");
|
|
710
893
|
await this.musicPreviewPlayer.play(structuredClone(found.played), found.album ? { album: structuredClone(found.album) } : {});
|
|
711
894
|
}
|
|
@@ -3298,15 +3481,18 @@ export class MoondogApplication {
|
|
|
3298
3481
|
...this.webLookupOptions(),
|
|
3299
3482
|
musicPreviewReady: this.musicPreviewReady(),
|
|
3300
3483
|
tasteChoicesReady: this.tasteChoicesReady(),
|
|
3484
|
+
localGenerationReady: this.localGenerationReady(),
|
|
3301
3485
|
}).map(capability => capability.state === "enabled" && this.#playbackServiceExcludes(capability.id)
|
|
3302
3486
|
? { ...capability, state: "blocked", blocked_by: capability.id === "listening.now" ? "no_readable_player" : capability.id === "music_app.control" ? "no_music_app" : capability.id === "playback.service" ? "no_playback_service"
|
|
3303
|
-
: neteaseCapabilities.has(capability.id) ? "netease_not_ready" : "
|
|
3487
|
+
: neteaseCapabilities.has(capability.id) ? "netease_not_ready" : capability.id.startsWith("music.generate.stream") ? "stream_not_ready"
|
|
3488
|
+
: "playback_service_not_spotify" } : capability),
|
|
3304
3489
|
};
|
|
3305
3490
|
}
|
|
3306
3491
|
|
|
3307
3492
|
// With another music service chosen, Spotify does not play or queue anything; NetEase's
|
|
3308
3493
|
// tools exist only while it plays. What the listener plays is read wherever it can be.
|
|
3309
3494
|
#playbackServiceExcludes(capabilityId) {
|
|
3495
|
+
if (capabilityId.startsWith("music.generate.stream")) return !this.streamReady();
|
|
3310
3496
|
if (capabilityId === "listening.now") return !this.listeningReadable();
|
|
3311
3497
|
if (capabilityId === "music_app.control") return this.musicApp?.available !== true;
|
|
3312
3498
|
if (capabilityId === "playback.service") return !this.playbackStatus().services.some((entry) => entry.available);
|
|
@@ -3333,6 +3519,7 @@ export class MoondogApplication {
|
|
|
3333
3519
|
...this.webLookupOptions(),
|
|
3334
3520
|
musicPreviewReady: this.musicPreviewReady(),
|
|
3335
3521
|
tasteChoicesReady: this.tasteChoicesReady(),
|
|
3522
|
+
localGenerationReady: this.localGenerationReady(),
|
|
3336
3523
|
}).filter(descriptor => !this.#playbackServiceExcludes(descriptor.capability_id));
|
|
3337
3524
|
}
|
|
3338
3525
|
|
|
@@ -3806,6 +3993,12 @@ export class MoondogApplication {
|
|
|
3806
3993
|
ok: true,
|
|
3807
3994
|
state: this.spotifyReady() ? "spotify_control" : "disabled",
|
|
3808
3995
|
},
|
|
3996
|
+
{
|
|
3997
|
+
id: "music.generate.local",
|
|
3998
|
+
ok: this.localGenerationReady(),
|
|
3999
|
+
state: this.generationStatus()?.state ?? "unavailable",
|
|
4000
|
+
optional_for_local_commands: true,
|
|
4001
|
+
},
|
|
3809
4002
|
{
|
|
3810
4003
|
id: "spotify.connection",
|
|
3811
4004
|
ok: this.spotifyReady(),
|
package/src/i18n/messages/de.mjs
CHANGED
|
@@ -130,6 +130,8 @@ export default {
|
|
|
130
130
|
"choice.like": ({ title, artist }) => { const name = title ? `„${title}“ von ${artist}` : artist; return `\n\nGespeichert: Du magst ${name}. Sag mir jederzeit, wenn du das ändern willst.`; },
|
|
131
131
|
"choice.avoid": ({ title, artist }) => { const name = title ? `„${title}“ von ${artist}` : artist; return `\n\nGespeichert: Ich lasse ${name} aus allem heraus, was ich spiele und vorschlage. Sag mir jederzeit, wenn du das ändern willst.`; },
|
|
132
132
|
"choice.retract": ({ title, artist }) => { const name = title ? `„${title}“ von ${artist}` : artist; return `\n\nZurückgenommen: deine frühere Wahl zu ${name}.`; },
|
|
133
|
+
"generate.made": ({ title, length, seed }) => `Auf diesem Mac mit Stable Audio 3 gemacht: „${title}“ (${length}, Seed ${seed}).`,
|
|
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.`,
|
|
133
135
|
"turn.cancelled": "Weitere Schritte abgebrochen.",
|
|
134
136
|
"turn.interrupted": "Die Antwort wurde unterbrochen. Die erfassten Spotify-Ergebnisse stehen unten; keine Aktion wurde wiederholt.",
|
|
135
137
|
};
|
package/src/i18n/messages/en.mjs
CHANGED
|
@@ -131,6 +131,8 @@ export default {
|
|
|
131
131
|
"choice.like": ({ title, artist }) => { const name = title ? `"${title}" by ${artist}` : artist; return `\n\nSaved: you like ${name}. Tell me any time to change it.`; },
|
|
132
132
|
"choice.avoid": ({ title, artist }) => { const name = title ? `"${title}" by ${artist}` : artist; return `\n\nSaved: I'll keep ${name} out of what I play and suggest. Tell me any time to change it.`; },
|
|
133
133
|
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" by ${artist}` : artist; return `\n\nUndone: your earlier choice about ${name}.`; },
|
|
134
|
+
"generate.made": ({ title, length, seed }) => `Made on this Mac with Stable Audio 3: "${title}" (${length}, seed ${seed}).`,
|
|
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.`,
|
|
134
136
|
"turn.cancelled": "Cancelled further work.",
|
|
135
137
|
"turn.interrupted": "The response was interrupted. The recorded Spotify outcomes are below; no action was replayed.",
|
|
136
138
|
};
|