nixamp 0.48.0 → 0.49.0
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/dist/ai-keys.d.ts +1 -1
- package/dist/ai-keys.js +2 -0
- package/dist/ai-label.d.ts +210 -0
- package/dist/ai-label.js +725 -0
- package/dist/ai-radio.d.ts +11 -5
- package/dist/ai-radio.js +27 -11
- package/dist/ai-text.d.ts +2 -0
- package/dist/ai-text.js +4 -2
- package/dist/label-routes.d.ts +15 -0
- package/dist/label-routes.js +255 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +28 -1
- package/dist/station-cli.js +109 -0
- package/dist/station-guide.d.ts +1 -1
- package/dist/station-guide.js +8 -3
- package/dist/station-mcp.d.ts +247 -20
- package/dist/station-mcp.js +36 -0
- package/dist/station-metering.js +2 -2
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +15 -1
- package/package.json +1 -1
- package/src/ai-keys.ts +2 -0
- package/src/ai-label.ts +826 -0
- package/src/ai-radio.ts +30 -13
- package/src/ai-text.ts +6 -2
- package/src/label-routes.ts +268 -0
- package/src/server.ts +30 -1
- package/src/station-cli.ts +102 -0
- package/src/station-guide.ts +8 -3
- package/src/station-mcp.ts +34 -0
- package/src/station-metering.ts +2 -2
- package/src/station-usage.ts +15 -1
- package/web/dist/assets/{ar-ropwGSSV.js → ar-BEVjqkqM.js} +1 -1
- package/web/dist/assets/{cs-Bi7uRYjd.js → cs-DjazXQ15.js} +1 -1
- package/web/dist/assets/{da-B8OELPch.js → da-DnyF6SYK.js} +1 -1
- package/web/dist/assets/{de-DJOTeXET.js → de--q1ph9Ez.js} +1 -1
- package/web/dist/assets/{es-BIupzuzS.js → es-Do3DkBwD.js} +1 -1
- package/web/dist/assets/{fi-A6MoWkzZ.js → fi-B7sJTx1N.js} +1 -1
- package/web/dist/assets/{fr-DerIDIwl.js → fr-Dy7f5rGi.js} +1 -1
- package/web/dist/assets/{hi-CyUtRBH4.js → hi-C9vSnTQ4.js} +1 -1
- package/web/dist/assets/{hu-DD1vrIhY.js → hu-h-eueQE8.js} +1 -1
- package/web/dist/assets/{id-C6OAMzii.js → id-D8Ffd-Pc.js} +1 -1
- package/web/dist/assets/{it-Xy86vmU7.js → it-BKM_2rj1.js} +1 -1
- package/web/dist/assets/main-Dl_uRj22.js +22 -0
- package/web/dist/assets/{nl-4Q-K2Jf1.js → nl-Cj3ETrcl.js} +1 -1
- package/web/dist/assets/radio-zPgyISLf.js +1 -0
- package/web/dist/assets/{ru-CpxwOhC4.js → ru-B5zmGSnG.js} +1 -1
- package/web/dist/assets/{sv-aRvu7MWa.js → sv-BMtjA1uI.js} +1 -1
- package/web/dist/assets/{uk-BCwwfngg.js → uk-B2vuB6Un.js} +1 -1
- package/web/dist/assets/{vi-tvWPiAH_.js → vi-CXam3lDh.js} +1 -1
- package/web/dist/assets/{zh-Bp2aLEyX.js → zh-DnYhyzqb.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +33 -1
- package/web/dist/sw.js +20 -20
- package/web/dist/assets/main-CuG8-ADK.js +0 -22
- package/web/dist/assets/radio-DI0H8flN.js +0 -1
package/dist/ai-radio.d.ts
CHANGED
|
@@ -52,22 +52,26 @@ export declare const DEFAULTS: {
|
|
|
52
52
|
export interface Composer {
|
|
53
53
|
/** e.g. "elevenlabs/music_v1". */
|
|
54
54
|
name: string;
|
|
55
|
-
/** Writes an MP3 to `out`. Throws OutOfCredit when the account cannot pay. */
|
|
55
|
+
/** Writes an MP3 to `out`. Throws OutOfCredit when the account cannot pay. `lyrics` are the words to sing, when there are any. */
|
|
56
56
|
compose(input: {
|
|
57
57
|
prompt: string;
|
|
58
58
|
seconds: number;
|
|
59
59
|
vocals: boolean;
|
|
60
60
|
out: string;
|
|
61
|
+
lyrics?: string;
|
|
61
62
|
}): Promise<void>;
|
|
62
63
|
}
|
|
63
64
|
export declare class OutOfCredit extends Error {
|
|
64
65
|
}
|
|
65
|
-
/** ElevenLabs
|
|
66
|
-
export declare
|
|
66
|
+
/** ElevenLabs' music models; NIXAMP_MUSIC_MODEL picks one, the newest by default. */
|
|
67
|
+
export declare const MUSIC_MODELS: readonly ["music_v1", "music_v2", "music_v2_5"];
|
|
68
|
+
/** ElevenLabs Music: a paid plan's music may be broadcast. Given lyrics, it sings those words. */
|
|
69
|
+
export declare function elevenLabsComposer(key: string, fetcher?: typeof fetch, model?: string): Composer;
|
|
67
70
|
/**
|
|
68
71
|
* A model of the owner's own (MusicGen, ACE-Step...): NIXAMP_AI_MUSIC_COMMAND
|
|
69
|
-
* is run with {prompt}, {seconds}, {vocals}
|
|
70
|
-
*
|
|
72
|
+
* is run with {prompt}, {seconds}, {vocals}, {lyrics} (a file of the words to
|
|
73
|
+
* sing, "" when there are none) and {out} replaced, each as one argument, and
|
|
74
|
+
* must leave an MP3 at {out}.
|
|
71
75
|
*/
|
|
72
76
|
export declare function commandComposer(template: string): Composer;
|
|
73
77
|
/** The composer this server can use, or null with the reason. */
|
|
@@ -92,6 +96,8 @@ export interface AiRadioDeps {
|
|
|
92
96
|
/** The folder the music goes in: a managed library source. */
|
|
93
97
|
root: string;
|
|
94
98
|
owner: string;
|
|
99
|
+
/** Who holds the rights in what is made ("Profullstack, Inc."), written as publisher and copyright; "" for none. */
|
|
100
|
+
holder?: () => string;
|
|
95
101
|
/** Whether another station may be started now (the server's running limit). */
|
|
96
102
|
mayStart: () => boolean;
|
|
97
103
|
composer?: () => Composer | null;
|
package/dist/ai-radio.js
CHANGED
|
@@ -43,16 +43,22 @@ const MAX_TRACKS = 5_000;
|
|
|
43
43
|
const BACKOFF_MS = [5 * 60_000, 10 * 60_000, 20 * 60_000, 40 * 60_000, 80 * 60_000, 6 * 3600_000];
|
|
44
44
|
export class OutOfCredit extends Error {
|
|
45
45
|
}
|
|
46
|
-
/** ElevenLabs
|
|
47
|
-
export
|
|
46
|
+
/** ElevenLabs' music models; NIXAMP_MUSIC_MODEL picks one, the newest by default. */
|
|
47
|
+
export const MUSIC_MODELS = ["music_v1", "music_v2", "music_v2_5"];
|
|
48
|
+
/** ElevenLabs Music: a paid plan's music may be broadcast. Given lyrics, it sings those words. */
|
|
49
|
+
export function elevenLabsComposer(key, fetcher = fetch, model = "music_v2_5") {
|
|
48
50
|
return {
|
|
49
|
-
name:
|
|
50
|
-
async compose({ prompt, seconds, vocals, out }) {
|
|
51
|
+
name: `elevenlabs/${model}`,
|
|
52
|
+
async compose({ prompt, seconds, vocals, out, lyrics }) {
|
|
53
|
+
const words = vocals && lyrics?.trim() ? lyrics.trim() : "";
|
|
51
54
|
const ask = async (text) => fetcher("https://api.elevenlabs.io/v1/music?output_format=mp3_44100_192", {
|
|
52
55
|
method: "POST",
|
|
53
56
|
signal: AbortSignal.timeout(10 * 60_000),
|
|
54
57
|
headers: { "content-type": "application/json", "xi-api-key": key },
|
|
55
|
-
body: JSON.stringify({
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
prompt: text, music_length_ms: Math.round(seconds * 1000), model_id: model,
|
|
60
|
+
...(words ? { lyrics_text: words } : { force_instrumental: !vocals }),
|
|
61
|
+
}),
|
|
56
62
|
});
|
|
57
63
|
let response = await ask(prompt);
|
|
58
64
|
if (response.status === 400) {
|
|
@@ -81,16 +87,22 @@ export function elevenLabsComposer(key, fetcher = fetch) {
|
|
|
81
87
|
}
|
|
82
88
|
/**
|
|
83
89
|
* A model of the owner's own (MusicGen, ACE-Step...): NIXAMP_AI_MUSIC_COMMAND
|
|
84
|
-
* is run with {prompt}, {seconds}, {vocals}
|
|
85
|
-
*
|
|
90
|
+
* is run with {prompt}, {seconds}, {vocals}, {lyrics} (a file of the words to
|
|
91
|
+
* sing, "" when there are none) and {out} replaced, each as one argument, and
|
|
92
|
+
* must leave an MP3 at {out}.
|
|
86
93
|
*/
|
|
87
94
|
export function commandComposer(template) {
|
|
88
95
|
const words = template.trim().split(/\s+/);
|
|
89
96
|
return {
|
|
90
97
|
name: `command/${words[0]?.split("/").pop() ?? "music"}`,
|
|
91
|
-
compose: ({ prompt, seconds, vocals, out }) => new Promise((resolve, reject) => {
|
|
92
|
-
const
|
|
98
|
+
compose: ({ prompt, seconds, vocals, out, lyrics }) => new Promise((resolve, reject) => {
|
|
99
|
+
const lyricsFile = lyrics?.trim() ? `${out}.lyrics.txt` : "";
|
|
100
|
+
if (lyricsFile)
|
|
101
|
+
writeFileSync(lyricsFile, `${lyrics.trim()}\n`);
|
|
102
|
+
const argv = words.map((one) => one.replace("{prompt}", prompt).replace("{seconds}", String(seconds)).replace("{vocals}", vocals ? "1" : "0").replace("{lyrics}", lyricsFile).replace("{out}", out));
|
|
93
103
|
execFile(argv[0], argv.slice(1), { timeout: 30 * 60_000, maxBuffer: 4 * 1024 * 1024 }, (error) => {
|
|
104
|
+
if (lyricsFile)
|
|
105
|
+
rmSync(lyricsFile, { force: true });
|
|
94
106
|
if (error)
|
|
95
107
|
return reject(new Error(`the music command failed: ${error.message}`));
|
|
96
108
|
try {
|
|
@@ -111,7 +123,8 @@ export function composerFromEnv(env = process.env) {
|
|
|
111
123
|
if (command)
|
|
112
124
|
return commandComposer(command);
|
|
113
125
|
const key = aiKey("ELEVENLABS_API_KEY", env);
|
|
114
|
-
|
|
126
|
+
const model = aiKey("NIXAMP_MUSIC_MODEL", env);
|
|
127
|
+
return key ? elevenLabsComposer(key, fetch, MUSIC_MODELS.includes(model) ? model : "music_v2_5") : null;
|
|
115
128
|
}
|
|
116
129
|
const MOODS = ["dreamy", "driving", "melancholy", "bright", "hazy", "tense", "playful", "warm", "nocturnal", "triumphant"];
|
|
117
130
|
const WORDS = ["Neon", "Harbor", "Velvet", "Static", "Paper", "Lantern", "Satellite", "Ember", "Glass", "Tide", "Orchard", "Signal"];
|
|
@@ -417,13 +430,16 @@ export class AiRadio {
|
|
|
417
430
|
const month = monthOf(this.now());
|
|
418
431
|
this.usage[month] = (this.usage[month] ?? 0) + station.trackSeconds;
|
|
419
432
|
const file = join(folder, `${String(station.tracks.length + 1).padStart(4, "0")} ${slug(brief.title)}.mp3`);
|
|
433
|
+
const year = new Date(this.now()).getUTCFullYear().toString();
|
|
434
|
+
const holder = this.deps.holder?.() ?? "";
|
|
420
435
|
await (this.deps.tag ?? ((from, to, tags) => tag(this.deps.ffmpeg, from, to, tags)))(raw, file, {
|
|
421
436
|
title: brief.title,
|
|
422
437
|
artist: `${station.name} (AI)`,
|
|
423
438
|
album: station.name,
|
|
424
439
|
genre: "AI",
|
|
425
440
|
comment: `Composed by ${composer.name} from a brief by ${brief.model}`,
|
|
426
|
-
date:
|
|
441
|
+
date: year,
|
|
442
|
+
...(holder ? { publisher: holder, copyright: `℗ ${year} ${holder}` } : {}),
|
|
427
443
|
});
|
|
428
444
|
const track = { file, title: brief.title, prompt: brief.prompt, seconds: station.trackSeconds, made: `${brief.model} + ${composer.name}`, createdAt: this.now() };
|
|
429
445
|
station.tracks.push(track);
|
package/dist/ai-text.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface AskOptions {
|
|
|
5
5
|
prompt: string;
|
|
6
6
|
/** Milliseconds before giving up (default 30 s). */
|
|
7
7
|
timeoutMs?: number;
|
|
8
|
+
/** The longest answer, in tokens (default 1024): a song's words need more than a title does. */
|
|
9
|
+
maxTokens?: number;
|
|
8
10
|
fetcher?: typeof fetch;
|
|
9
11
|
env?: Record<string, string | undefined>;
|
|
10
12
|
}
|
package/dist/ai-text.js
CHANGED
|
@@ -58,7 +58,7 @@ export async function askJson(options) {
|
|
|
58
58
|
body: JSON.stringify({
|
|
59
59
|
systemInstruction: { parts: [{ text: options.system }] },
|
|
60
60
|
contents: [{ role: "user", parts: [{ text: options.prompt }] }],
|
|
61
|
-
generationConfig: { responseMimeType: "application/json", temperature: 0.9 },
|
|
61
|
+
generationConfig: { responseMimeType: "application/json", temperature: 0.9, maxOutputTokens: options.maxTokens ?? 1024 },
|
|
62
62
|
}),
|
|
63
63
|
});
|
|
64
64
|
if (!response.ok) {
|
|
@@ -82,6 +82,8 @@ export async function askJson(options) {
|
|
|
82
82
|
headers: { "content-type": "application/json", authorization: `Bearer ${openai}` },
|
|
83
83
|
body: JSON.stringify({
|
|
84
84
|
model,
|
|
85
|
+
// Workers AI stops at 256 tokens unless it is told more.
|
|
86
|
+
max_tokens: options.maxTokens ?? 1024,
|
|
85
87
|
// Not every compatible server knows json_object; the answer is read for its JSON either way.
|
|
86
88
|
...(official ? { response_format: { type: "json_object" } } : {}),
|
|
87
89
|
messages: [{ role: "system", content: options.system }, { role: "user", content: options.prompt }],
|
|
@@ -101,7 +103,7 @@ export async function askJson(options) {
|
|
|
101
103
|
headers: { "content-type": "application/json", "x-api-key": anthropic, "anthropic-version": "2023-06-01" },
|
|
102
104
|
body: JSON.stringify({
|
|
103
105
|
model,
|
|
104
|
-
max_tokens: 1024,
|
|
106
|
+
max_tokens: options.maxTokens ?? 1024,
|
|
105
107
|
system: `${options.system}\nAnswer with one JSON object and nothing else.`,
|
|
106
108
|
messages: [{ role: "user", content: options.prompt }],
|
|
107
109
|
}),
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { type AiLabel } from "./ai-label.ts";
|
|
3
|
+
/** Whether this path is the label's public face. */
|
|
4
|
+
export declare function isPublicLabelPath(path: string): boolean;
|
|
5
|
+
export interface LabelRoutesContext {
|
|
6
|
+
label: AiLabel;
|
|
7
|
+
canControl: boolean;
|
|
8
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
9
|
+
readBody: (request: IncomingMessage) => Promise<string>;
|
|
10
|
+
}
|
|
11
|
+
/** Answers the paths above and the owner's; false when the path is neither. */
|
|
12
|
+
export declare function handleLabelRoutes(request: IncomingMessage, response: ServerResponse, path: string, ctx: LabelRoutesContext): Promise<boolean>;
|
|
13
|
+
export declare const LABEL_CSP: string;
|
|
14
|
+
/** The label's catalogue as a page: no script, plays in any browser. */
|
|
15
|
+
export declare function renderLabel(view: ReturnType<AiLabel["publicView"]>): string;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI label over HTTP (ai-label.ts).
|
|
3
|
+
*
|
|
4
|
+
* Public, no key: `/label` (the catalogue as a page: every artist, album and
|
|
5
|
+
* song, playable, with its words), `/api/v1/label` (the same as JSON) and
|
|
6
|
+
* `/api/v1/label/<artist>/<album>/<n>.mp3|<n>.txt|cover.jpg` (the released
|
|
7
|
+
* files themselves). The owner's are under /api/v1/stations/label.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { createReadStream, statSync } from "node:fs";
|
|
11
|
+
import { ROSTER } from "./ai-label.js";
|
|
12
|
+
import { StationError } from "./stations.js";
|
|
13
|
+
// Listening is free on the live stations (with their breaks); the songs as
|
|
14
|
+
// files are sold, so only the words and the cover are public here.
|
|
15
|
+
const FILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/([a-f0-9]{8})\/(\d{1,3}\.txt|cover\.jpg)$/;
|
|
16
|
+
/** Whether this path is the label's public face. */
|
|
17
|
+
export function isPublicLabelPath(path) {
|
|
18
|
+
return path === "/label" || path === "/api/v1/label" || FILE.test(path);
|
|
19
|
+
}
|
|
20
|
+
async function jsonBody(ctx, request) {
|
|
21
|
+
const raw = await ctx.readBody(request);
|
|
22
|
+
if (raw.trim() === "")
|
|
23
|
+
return {};
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new StationError("bad_json", "the body is not JSON");
|
|
30
|
+
}
|
|
31
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
32
|
+
throw new StationError("bad_json", "the body is not a JSON object");
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
/** A file, whole or the byte range asked for, so a player can seek. */
|
|
36
|
+
function sendFile(request, response, path, type, download) {
|
|
37
|
+
let size = 0;
|
|
38
|
+
try {
|
|
39
|
+
size = statSync(path).size;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
response.writeHead(404, { "content-type": "text/plain" }).end("gone");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const headers = {
|
|
46
|
+
"content-type": type,
|
|
47
|
+
"accept-ranges": "bytes",
|
|
48
|
+
"cache-control": "public, max-age=3600",
|
|
49
|
+
"access-control-allow-origin": "*",
|
|
50
|
+
...(download ? { "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(download)}` } : {}),
|
|
51
|
+
};
|
|
52
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(String(request.headers.range ?? ""));
|
|
53
|
+
if (range && size > 0) {
|
|
54
|
+
const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2] || 0));
|
|
55
|
+
const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
|
|
56
|
+
if (start > end || start >= size) {
|
|
57
|
+
response.writeHead(416, { "content-range": `bytes */${size}` }).end();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
response.writeHead(206, { ...headers, "content-range": `bytes ${start}-${end}/${size}`, "content-length": String(end - start + 1) });
|
|
61
|
+
if (request.method === "HEAD")
|
|
62
|
+
return void response.end();
|
|
63
|
+
createReadStream(path, { start, end }).pipe(response);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
response.writeHead(200, { ...headers, "content-length": String(size) });
|
|
67
|
+
if (request.method === "HEAD")
|
|
68
|
+
return void response.end();
|
|
69
|
+
createReadStream(path).pipe(response);
|
|
70
|
+
}
|
|
71
|
+
/** Answers the paths above and the owner's; false when the path is neither. */
|
|
72
|
+
export async function handleLabelRoutes(request, response, path, ctx) {
|
|
73
|
+
const method = request.method ?? "GET";
|
|
74
|
+
const label = ctx.label;
|
|
75
|
+
try {
|
|
76
|
+
if (isPublicLabelPath(path)) {
|
|
77
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
78
|
+
ctx.json(response, 405, { error: "not like that", code: "method_not_allowed" });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (path === "/label") {
|
|
82
|
+
const page = renderLabel(label.publicView());
|
|
83
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": LABEL_CSP, "cache-control": "public, max-age=300" });
|
|
84
|
+
response.end(method === "HEAD" ? undefined : page);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (path === "/api/v1/label") {
|
|
88
|
+
ctx.json(response, 200, label.publicView());
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
const [, artist, album, what] = FILE.exec(path);
|
|
92
|
+
const found = label.publicFile(artist, album, what);
|
|
93
|
+
if (!found) {
|
|
94
|
+
ctx.json(response, 404, { error: "no such file on the label", code: "not_found" });
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
sendFile(request, response, found.path, found.type, what === "cover.jpg" ? "" : found.path.split("/").pop() ?? "");
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (path !== "/api/v1/stations/label" && !path.startsWith("/api/v1/stations/label/"))
|
|
101
|
+
return false;
|
|
102
|
+
if (!ctx.canControl) {
|
|
103
|
+
ctx.json(response, 403, { error: "only this server's owner runs the label", code: "forbidden" });
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (path === "/api/v1/stations/label") {
|
|
107
|
+
if (method === "GET") {
|
|
108
|
+
ctx.json(response, 200, label.status());
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (method === "POST") {
|
|
112
|
+
ctx.json(response, 200, { settings: label.configure(await jsonBody(ctx, request)) });
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (path === "/api/v1/stations/label/run" && method === "POST") {
|
|
117
|
+
void label.tick();
|
|
118
|
+
ctx.json(response, 202, label.status());
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (path === "/api/v1/stations/label/artists" && method === "POST") {
|
|
122
|
+
const input = await jsonBody(ctx, request);
|
|
123
|
+
if (input["roster"] === true) {
|
|
124
|
+
// The whole first roster at once, skipping whoever is already signed.
|
|
125
|
+
const signed = Object.keys(ROSTER).flatMap((preset) => {
|
|
126
|
+
try {
|
|
127
|
+
return [label.sign({ preset })];
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error instanceof StationError && error.code === "already_signed")
|
|
131
|
+
return [];
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
ctx.json(response, 201, { artists: signed });
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
ctx.json(response, 201, { artist: label.sign(input) });
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
const match = /^\/api\/v1\/stations\/label\/artists\/([a-f0-9]{10})(?:\/(album))?$/.exec(path);
|
|
142
|
+
if (match) {
|
|
143
|
+
const [, key, action] = match;
|
|
144
|
+
if (!action && method === "GET") {
|
|
145
|
+
const artist = label.get(key);
|
|
146
|
+
if (!artist)
|
|
147
|
+
throw new StationError("not_found", "no such artist on the label", 404);
|
|
148
|
+
ctx.json(response, 200, { artist });
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
if (!action && method === "PATCH") {
|
|
152
|
+
ctx.json(response, 200, { artist: label.update(key, await jsonBody(ctx, request)) });
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
if (!action && method === "DELETE") {
|
|
156
|
+
await label.drop(key, new URL(request.url ?? "/", "http://x").searchParams.get("files") === "1");
|
|
157
|
+
ctx.json(response, 200, { deleted: key });
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
if (action === "album" && method === "POST") {
|
|
161
|
+
const album = await label.plan(key);
|
|
162
|
+
void label.tick();
|
|
163
|
+
ctx.json(response, 201, { album });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
ctx.json(response, 404, { error: "no such label route", code: "not_found" });
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error instanceof StationError)
|
|
172
|
+
ctx.json(response, error.status, { error: error.message, code: error.code });
|
|
173
|
+
else
|
|
174
|
+
ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function escape(text) {
|
|
179
|
+
return text.replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]);
|
|
180
|
+
}
|
|
181
|
+
function minutes(seconds) {
|
|
182
|
+
return `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
|
|
183
|
+
}
|
|
184
|
+
const STYLE = `
|
|
185
|
+
:root{color-scheme:light dark;--bg:#f6f7f9;--fg:#15171a;--muted:#5d6570;--card:#fff;--line:#dfe3e8;--accent:#0f7b4f}
|
|
186
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0d0f12;--fg:#e8eaee;--muted:#9aa3ad;--card:#161a1f;--line:#262c33;--accent:#3ddc84}}
|
|
187
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
|
188
|
+
main{max-width:960px;margin:0 auto;padding:32px 16px 48px}h1{margin:0 0 4px;font-size:1.8rem}
|
|
189
|
+
.lead{color:var(--muted);margin:0 0 24px}a{color:var(--accent)}
|
|
190
|
+
nav.genres{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 8px}nav.genres a{border:1px solid var(--line);border-radius:999px;padding:4px 12px;text-decoration:none;color:var(--fg);background:var(--card)}
|
|
191
|
+
section.genre{margin-top:36px}section.genre>h2{display:flex;flex-wrap:wrap;align-items:baseline;gap:12px;margin:0 0 12px;font-size:1.4rem}
|
|
192
|
+
section.genre>h2 a{font-size:.9rem;font-weight:600}
|
|
193
|
+
.artist{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin:12px 0}
|
|
194
|
+
.artist h3{margin:0;font-size:1.2rem}.bio{color:var(--muted);margin:4px 0 0}
|
|
195
|
+
.album{display:grid;grid-template-columns:160px 1fr;gap:16px;margin-top:18px}
|
|
196
|
+
@media (max-width:560px){.album{grid-template-columns:1fr}}
|
|
197
|
+
.cover{width:160px;height:160px;border-radius:10px;object-fit:cover;background:var(--line);display:block}
|
|
198
|
+
.blank{width:160px;height:160px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--line))}
|
|
199
|
+
.album h4{margin:0;font-size:1.05rem}.concept{color:var(--muted);font-size:.9rem;margin:2px 0 8px}
|
|
200
|
+
ol.songs{list-style:none;margin:0;padding:0}ol.songs li{border-top:1px solid var(--line);padding:8px 0}
|
|
201
|
+
.song{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center}.song .t{flex:1 1 200px;font-weight:600;overflow-wrap:anywhere}
|
|
202
|
+
.song .n,.song .d{color:var(--muted);font-variant-numeric:tabular-nums}
|
|
203
|
+
audio{width:100%;max-width:360px;height:36px}
|
|
204
|
+
details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:.9rem}
|
|
205
|
+
pre.lyrics{white-space:pre-wrap;font:inherit;font-size:.95rem;margin:8px 0 0;padding:12px;border-radius:8px;background:var(--bg)}
|
|
206
|
+
.dl{font-size:.85rem}.soon{color:var(--muted);font-size:.85rem}
|
|
207
|
+
footer{margin-top:48px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
|
|
208
|
+
`;
|
|
209
|
+
function hash(text) {
|
|
210
|
+
return `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
211
|
+
}
|
|
212
|
+
export const LABEL_CSP = [
|
|
213
|
+
"default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'",
|
|
214
|
+
"base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
|
|
215
|
+
].join("; ");
|
|
216
|
+
function renderAlbum(artist, album) {
|
|
217
|
+
const base = `/api/v1/label/${artist.key}/${album.key}`;
|
|
218
|
+
const cover = album.cover ? `<img class="cover" src="${base}/cover.jpg" alt="Cover of ${escape(album.title)}" width="160" height="160" loading="lazy">` : `<div class="blank" aria-hidden="true"></div>`;
|
|
219
|
+
const songs = album.songs.map((song) => `<li><div class="song"><span class="n">${song.n}.</span><span class="t">${escape(song.title)}</span><span class="d">${minutes(song.seconds)}</span></div>`
|
|
220
|
+
+ `<details><summary>Lyrics</summary><pre class="lyrics">${escape(song.lyrics)}</pre></details>`
|
|
221
|
+
+ `<div class="dl"><a href="${base}/${song.n}.txt">lyrics.txt</a></div></li>`).join("");
|
|
222
|
+
return `<div class="album">${cover}<div><h4>${escape(album.title)} <span class="d">(${album.year})</span></h4>`
|
|
223
|
+
+ `${album.concept ? `<p class="concept">${escape(album.concept)}</p>` : ""}`
|
|
224
|
+
+ `${album.complete ? "" : `<p class="soon">Still being recorded: new songs land here as they are finished.</p>`}`
|
|
225
|
+
+ `<ol class="songs">${songs}</ol></div></div>`;
|
|
226
|
+
}
|
|
227
|
+
/** The label's catalogue as a page: no script, plays in any browser. */
|
|
228
|
+
export function renderLabel(view) {
|
|
229
|
+
const label = view.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
|
|
230
|
+
const nav = view.genres.length > 1 ? `<nav class="genres" aria-label="Genres">${view.genres.map((one) => `<a href="#${escape(one.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">${escape(one.genre)}</a>`).join("")}</nav>` : "";
|
|
231
|
+
const sections = view.genres.map((genre) => `<section class="genre" id="${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}" aria-labelledby="g-${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">`
|
|
232
|
+
+ `<h2 id="g-${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">${escape(genre.genre)}${genre.station ? ` <a href="/watch/${escape(genre.station)}">Listen live, free →</a>` : ""}</h2>`
|
|
233
|
+
+ genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}${artist.albums.map((album) => renderAlbum(artist, album)).join("")}</article>`).join("")
|
|
234
|
+
+ `</section>`).join("");
|
|
235
|
+
return `<!doctype html>
|
|
236
|
+
<html lang="en">
|
|
237
|
+
<head>
|
|
238
|
+
<meta charset="utf-8">
|
|
239
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
240
|
+
<title>${escape(label)} AI Label</title>
|
|
241
|
+
<meta name="description" content="Original music by ${escape(label)}'s AI artists: every album, every song, with the words. New albums every month.">
|
|
242
|
+
<link rel="icon" href="data:,">
|
|
243
|
+
<style>${STYLE}</style>
|
|
244
|
+
</head>
|
|
245
|
+
<body>
|
|
246
|
+
<main>
|
|
247
|
+
<h1>${escape(label)} AI Label</h1>
|
|
248
|
+
<p class="lead">Original artists, written and sung by AI, released by ${escape(view.holder)}. Every album, every song, with the words. New albums land every month. Listening on the live stations is always free.</p>
|
|
249
|
+
${nav}
|
|
250
|
+
${sections || `<p class="soon">The first albums are being recorded. Check back soon.</p>`}
|
|
251
|
+
<footer>℗ ${new Date().getUTCFullYear()} ${escape(view.holder)}. Music and words made with AI. Played by <a href="https://nixamp.com">nixamp</a>.</footer>
|
|
252
|
+
</main>
|
|
253
|
+
</body>
|
|
254
|
+
</html>`;
|
|
255
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { HlsPackagers } from "./hls.ts";
|
|
|
24
24
|
import { LibraryIndex, type Probed } from "./library-index.ts";
|
|
25
25
|
import { ListenerStore } from "./library-user.ts";
|
|
26
26
|
import { Importer } from "./handoff.ts";
|
|
27
|
+
import { AiLabel } from "./ai-label.ts";
|
|
27
28
|
import { AiRadio } from "./ai-radio.ts";
|
|
28
29
|
import { ListenerShares } from "./listener-shares.ts";
|
|
29
30
|
import { Support, SupportSubscriptions } from "./support.ts";
|
|
@@ -599,6 +600,8 @@ export interface HandlerOptions {
|
|
|
599
600
|
publicBase?: () => string;
|
|
600
601
|
/** Stations whose music is composed by AI. */
|
|
601
602
|
ai?: AiRadio;
|
|
603
|
+
/** AI artists and their albums, a genre station each, and the catalogue at /label. */
|
|
604
|
+
label?: AiLabel;
|
|
602
605
|
/** Tracks anybody may share, sorted onto the air by AI. */
|
|
603
606
|
shares?: ListenerShares;
|
|
604
607
|
/** What a listener may know about the track on the air. */
|
package/dist/server.js
CHANGED
|
@@ -61,6 +61,8 @@ import { ListenerStore } from "./library-user.js";
|
|
|
61
61
|
import { Importer } from "./handoff.js";
|
|
62
62
|
import { handleHandoffs } from "./handoff-routes.js";
|
|
63
63
|
import { handleAiRoutes, isPublicSharePath } from "./ai-routes.js";
|
|
64
|
+
import { AiLabel } from "./ai-label.js";
|
|
65
|
+
import { handleLabelRoutes, isPublicLabelPath } from "./label-routes.js";
|
|
64
66
|
import { AiRadio } from "./ai-radio.js";
|
|
65
67
|
import { ListenerShares } from "./listener-shares.js";
|
|
66
68
|
import { HolderStore, TagReader, factsFor } from "./track-facts.js";
|
|
@@ -2177,6 +2179,8 @@ export function createHandler(engine, options) {
|
|
|
2177
2179
|
!isStationListenPath(path) &&
|
|
2178
2180
|
// Sharing a track and reporting one are for anybody the guide reaches.
|
|
2179
2181
|
!isPublicSharePath(path) &&
|
|
2182
|
+
// The AI label's catalogue, its songs and their words.
|
|
2183
|
+
!(isPublicLabelPath(path) && Boolean(options.stations?.label)) &&
|
|
2180
2184
|
// What is on, as MCP, for anybody.
|
|
2181
2185
|
path !== "/mcp/public" &&
|
|
2182
2186
|
// Supporting a copyright holder is for anybody listening.
|
|
@@ -2368,7 +2372,8 @@ export function createHandler(engine, options) {
|
|
|
2368
2372
|
? await roomEntries(options.stations.rooms.store, options.stations.rooms.probe, watchBase(options.stations.publicBase?.() || `${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`))
|
|
2369
2373
|
: [];
|
|
2370
2374
|
const ads = guideAds();
|
|
2371
|
-
const
|
|
2375
|
+
const label = options.stations.label;
|
|
2376
|
+
const page = renderGuide([...rooms, ...entries, ...others], options.stations.publicBase?.() ?? "", ads, label && label.publicView().genres.length > 0 ? "/label" : "");
|
|
2372
2377
|
response.writeHead(200, {
|
|
2373
2378
|
"content-type": "text/html; charset=utf-8",
|
|
2374
2379
|
"cache-control": "no-cache",
|
|
@@ -2523,6 +2528,11 @@ export function createHandler(engine, options) {
|
|
|
2523
2528
|
});
|
|
2524
2529
|
return;
|
|
2525
2530
|
}
|
|
2531
|
+
if (options.stations?.label && (isPublicLabelPath(path) || path === "/api/v1/stations/label" || path.startsWith("/api/v1/stations/label/"))) {
|
|
2532
|
+
const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
|
|
2533
|
+
if (await handleLabelRoutes(request, response, path, { label: options.stations.label, canControl: control.allowed, json, readBody }))
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2526
2536
|
if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares") || path.startsWith("/api/v1/stations/holders"))) {
|
|
2527
2537
|
const control = await controlOf();
|
|
2528
2538
|
// Behind a proxy we trust (dev2 for live.streaming), the sharer is the
|
|
@@ -6771,10 +6781,27 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6771
6781
|
ffmpeg: tools.ffmpeg,
|
|
6772
6782
|
root: join(stateDir(), "ai-music"),
|
|
6773
6783
|
owner: options.owner || "",
|
|
6784
|
+
holder: () => aiLabel.holder(),
|
|
6785
|
+
mayStart: mayStartStation,
|
|
6786
|
+
log: (line) => console.log(line),
|
|
6787
|
+
});
|
|
6788
|
+
// The AI label: artists who put out albums on their own, filed under the
|
|
6789
|
+
// library as ai/<genre>/<artist>/<year>-<album>/ (NIXAMP_LABEL_ROOT moves it).
|
|
6790
|
+
const labelHome = libraryIndex.sources().find((one) => one.enabled && !one.managed);
|
|
6791
|
+
const aiLabel = new AiLabel(AiLabel.fileFor(stateDir(), options.port), {
|
|
6792
|
+
index: libraryIndex,
|
|
6793
|
+
store: stationStore,
|
|
6794
|
+
runner: stationRunner,
|
|
6795
|
+
probe: probeFile,
|
|
6796
|
+
ffmpeg: tools.ffmpeg,
|
|
6797
|
+
root: process.env["NIXAMP_LABEL_ROOT"] || (labelHome ? join(labelHome.root, "ai") : join(stateDir(), "ai-label")),
|
|
6798
|
+
owner: options.owner || "",
|
|
6774
6799
|
mayStart: mayStartStation,
|
|
6775
6800
|
log: (line) => console.log(line),
|
|
6776
6801
|
});
|
|
6777
6802
|
aiRadio.startSchedule();
|
|
6803
|
+
aiLabel.startSchedule();
|
|
6804
|
+
stations.label = aiLabel;
|
|
6778
6805
|
// Tracks anybody shares from the guide, checked by AI before they air.
|
|
6779
6806
|
const listenerShares = new ListenerShares(ListenerShares.fileFor(stateDir(), options.port), {
|
|
6780
6807
|
index: libraryIndex,
|