nixamp 0.23.5 → 0.23.7
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 +28 -8
- package/dist/channels.d.ts +31 -0
- package/dist/channels.js +99 -4
- package/dist/main.js +28 -1
- package/dist/mcp.js +50 -0
- package/dist/outro.d.ts +48 -0
- package/dist/outro.js +225 -0
- package/dist/partyline.d.ts +3 -1
- package/dist/partyline.js +3 -1
- package/dist/profile.d.ts +45 -0
- package/dist/profile.js +148 -0
- package/dist/rtmp-in.js +6 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +41 -5
- package/dist/voices.d.ts +70 -7
- package/dist/voices.js +154 -25
- package/package.json +1 -1
- package/src/channels.ts +107 -4
- package/src/main.ts +28 -1
- package/src/mcp.ts +47 -0
- package/src/outro.ts +245 -0
- package/src/partyline.ts +3 -1
- package/src/profile.ts +174 -0
- package/src/rtmp-in.ts +5 -2
- package/src/server.ts +44 -5
- package/src/voices.ts +184 -22
- package/web/dist/assets/{hls-3VKVEQE3-Dq51v8Gv.js → hls-3VKVEQE3-CI1U7kbP.js} +1 -1
- package/web/dist/assets/index-5H3sUGHu.js +1 -0
- package/web/dist/assets/{mpegts-WAX5pHld.js → mpegts-CPOYjgRP.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dz2Fz7af.js → mpegts-LO6RVLD6-CE8YPjx1.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-zCUbE3U5.js +0 -1
package/src/outro.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outro: what a channel plays once its show is over.
|
|
3
|
+
*
|
|
4
|
+
* A live that ended used to start again from the top, for ever, because a
|
|
5
|
+
* source that finished cleanly was dialled like a source that dropped. Now
|
|
6
|
+
* it plays this: five seconds of "THIS LIVE STREAM HAS ENDED" on the
|
|
7
|
+
* plate with the mark, looped for an hour, so whoever joins late is told
|
|
8
|
+
* so by the picture and not by a blank panel. Then the channel closes.
|
|
9
|
+
*
|
|
10
|
+
* Drawn here, never committed as a file, the way the app's icons are:
|
|
11
|
+
* a bitmap, a 5x7 pixel face, and ffmpeg to make a clip of it. Encoded
|
|
12
|
+
* exactly as a channel is sent on the wire (H.264 and AAC for a picture,
|
|
13
|
+
* MP3 for sound alone) so the channel can copy it through without knowing
|
|
14
|
+
* how its own source was encoded. Made once per server and kept beside the
|
|
15
|
+
* keys; a missing or stale one is drawn again.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { deflateSync } from "node:zlib";
|
|
21
|
+
|
|
22
|
+
/** How long the outro plays after a show ends, before the channel closes. */
|
|
23
|
+
export const OUTRO_MS = 60 * 60 * 1000;
|
|
24
|
+
/** The clip's length; it loops. */
|
|
25
|
+
export const OUTRO_SECONDS = 5;
|
|
26
|
+
/** Bumped when the drawing changes, so an old clip on disk is drawn again. */
|
|
27
|
+
export const OUTRO_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
export interface Rgba { r: number; g: number; b: number; a: number }
|
|
30
|
+
|
|
31
|
+
const BACKGROUND: Rgba = { r: 8, g: 12, b: 9, a: 255 };
|
|
32
|
+
const GREEN: Rgba = { r: 74, g: 246, b: 137, a: 255 };
|
|
33
|
+
const DIM: Rgba = { r: 34, g: 122, b: 74, a: 255 };
|
|
34
|
+
const MUTED: Rgba = { r: 120, g: 150, b: 130, a: 255 };
|
|
35
|
+
const BARS = [0.30, 0.55, 0.85, 0.62, 1.0, 0.72, 0.45, 0.25];
|
|
36
|
+
|
|
37
|
+
/** A canvas of straight RGBA bytes. */
|
|
38
|
+
export class Bitmap {
|
|
39
|
+
readonly pixels: Uint8Array;
|
|
40
|
+
constructor(readonly width: number, readonly height: number) {
|
|
41
|
+
this.pixels = new Uint8Array(width * height * 4);
|
|
42
|
+
}
|
|
43
|
+
fill(color: Rgba): void {
|
|
44
|
+
for (let i = 0; i < this.pixels.length; i += 4) this.set(i, color);
|
|
45
|
+
}
|
|
46
|
+
rect(x: number, y: number, w: number, h: number, color: Rgba): void {
|
|
47
|
+
const x0 = Math.max(0, Math.round(x));
|
|
48
|
+
const y0 = Math.max(0, Math.round(y));
|
|
49
|
+
const x1 = Math.min(this.width, Math.round(x + w));
|
|
50
|
+
const y1 = Math.min(this.height, Math.round(y + h));
|
|
51
|
+
for (let py = y0; py < y1; py++) {
|
|
52
|
+
for (let px = x0; px < x1; px++) this.set((py * this.width + px) * 4, color);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
private set(offset: number, { r, g, b, a }: Rgba): void {
|
|
56
|
+
this.pixels[offset] = r;
|
|
57
|
+
this.pixels[offset + 1] = g;
|
|
58
|
+
this.pixels[offset + 2] = b;
|
|
59
|
+
this.pixels[offset + 3] = a;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CRC_TABLE = (() => {
|
|
64
|
+
const table = new Uint32Array(256);
|
|
65
|
+
for (let n = 0; n < 256; n++) {
|
|
66
|
+
let c = n;
|
|
67
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
68
|
+
table[n] = c >>> 0;
|
|
69
|
+
}
|
|
70
|
+
return table;
|
|
71
|
+
})();
|
|
72
|
+
|
|
73
|
+
function crc32(bytes: Uint8Array): number {
|
|
74
|
+
let c = 0xffffffff;
|
|
75
|
+
for (const byte of bytes) c = (CRC_TABLE[(c ^ byte) & 0xff] as number) ^ (c >>> 8);
|
|
76
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function chunk(type: string, data: Uint8Array): Buffer {
|
|
80
|
+
const head = Buffer.alloc(8);
|
|
81
|
+
head.writeUInt32BE(data.length, 0);
|
|
82
|
+
head.write(type, 4, "ascii");
|
|
83
|
+
const body = Buffer.concat([head.subarray(4), Buffer.from(data)]);
|
|
84
|
+
const tail = Buffer.alloc(4);
|
|
85
|
+
tail.writeUInt32BE(crc32(body), 0);
|
|
86
|
+
return Buffer.concat([head.subarray(0, 4), body, tail]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Encode as a non-interlaced 8-bit RGBA PNG. */
|
|
90
|
+
export function encodePng(bitmap: Bitmap): Buffer {
|
|
91
|
+
const { width, height, pixels } = bitmap;
|
|
92
|
+
const stride = width * 4;
|
|
93
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
94
|
+
for (let y = 0; y < height; y++) {
|
|
95
|
+
raw[y * (stride + 1)] = 0;
|
|
96
|
+
Buffer.from(pixels.subarray(y * stride, (y + 1) * stride)).copy(raw, y * (stride + 1) + 1);
|
|
97
|
+
}
|
|
98
|
+
const ihdr = Buffer.alloc(13);
|
|
99
|
+
ihdr.writeUInt32BE(width, 0);
|
|
100
|
+
ihdr.writeUInt32BE(height, 4);
|
|
101
|
+
ihdr[8] = 8;
|
|
102
|
+
ihdr[9] = 6;
|
|
103
|
+
return Buffer.concat([
|
|
104
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
105
|
+
chunk("IHDR", ihdr),
|
|
106
|
+
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
|
107
|
+
chunk("IEND", new Uint8Array(0)),
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A 5x7 pixel face: the letters the outro needs and nothing else. */
|
|
112
|
+
const GLYPHS: Record<string, string[]> = {
|
|
113
|
+
A: [".XXX.", "X...X", "X...X", "XXXXX", "X...X", "X...X", "X...X"],
|
|
114
|
+
C: [".XXXX", "X....", "X....", "X....", "X....", "X....", ".XXXX"],
|
|
115
|
+
D: ["XXXX.", "X...X", "X...X", "X...X", "X...X", "X...X", "XXXX."],
|
|
116
|
+
E: ["XXXXX", "X....", "X....", "XXXX.", "X....", "X....", "XXXXX"],
|
|
117
|
+
H: ["X...X", "X...X", "X...X", "XXXXX", "X...X", "X...X", "X...X"],
|
|
118
|
+
I: ["XXXXX", "..X..", "..X..", "..X..", "..X..", "..X..", "XXXXX"],
|
|
119
|
+
L: ["X....", "X....", "X....", "X....", "X....", "X....", "XXXXX"],
|
|
120
|
+
M: ["X...X", "XX.XX", "X.X.X", "X.X.X", "X...X", "X...X", "X...X"],
|
|
121
|
+
N: ["X...X", "XX..X", "X.X.X", "X..XX", "X...X", "X...X", "X...X"],
|
|
122
|
+
O: [".XXX.", "X...X", "X...X", "X...X", "X...X", "X...X", ".XXX."],
|
|
123
|
+
P: ["XXXX.", "X...X", "X...X", "XXXX.", "X....", "X....", "X...."],
|
|
124
|
+
R: ["XXXX.", "X...X", "X...X", "XXXX.", "X.X..", "X..X.", "X...X"],
|
|
125
|
+
S: [".XXXX", "X....", "X....", ".XXX.", "....X", "....X", "XXXX."],
|
|
126
|
+
T: ["XXXXX", "..X..", "..X..", "..X..", "..X..", "..X..", "..X.."],
|
|
127
|
+
V: ["X...X", "X...X", "X...X", "X...X", "X...X", ".X.X.", "..X.."],
|
|
128
|
+
X: ["X...X", "X...X", ".X.X.", "..X..", ".X.X.", "X...X", "X...X"],
|
|
129
|
+
".": [".....", ".....", ".....", ".....", ".....", "..XX.", "..XX."],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export function textWidth(text: string, cell: number): number {
|
|
133
|
+
return text.length * 6 * cell - cell;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function drawText(bitmap: Bitmap, text: string, x: number, y: number, cell: number, color: Rgba): void {
|
|
137
|
+
let at = x;
|
|
138
|
+
for (const letter of text) {
|
|
139
|
+
const rows = GLYPHS[letter];
|
|
140
|
+
if (rows) {
|
|
141
|
+
rows.forEach((row, r) => {
|
|
142
|
+
for (let c = 0; c < row.length; c++) if (row[c] === "X") bitmap.rect(at + c * cell, y + r * cell, cell, cell, color);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
at += 6 * cell;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function drawBars(bitmap: Bitmap, left: number, bottom: number, width: number, tallest: number, scale: number): void {
|
|
150
|
+
const slot = width / BARS.length;
|
|
151
|
+
const bar = slot * 0.62;
|
|
152
|
+
BARS.forEach((value, i) => {
|
|
153
|
+
const height = Math.max(2, tallest * value);
|
|
154
|
+
const x = left + i * slot + (slot - bar) / 2;
|
|
155
|
+
bitmap.rect(x, bottom - height, bar, height, GREEN);
|
|
156
|
+
const peak = Math.max(2, scale * 0.018);
|
|
157
|
+
bitmap.rect(x, bottom - height - peak * 2.4, bar, peak, DIM);
|
|
158
|
+
});
|
|
159
|
+
bitmap.rect(left, bottom, width, Math.max(2, scale * 0.028), DIM);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The picture: the mark above the words, on the plate. 1280x720. */
|
|
163
|
+
export function drawOutro(width = 1280, height = 720): Bitmap {
|
|
164
|
+
const bitmap = new Bitmap(width, height);
|
|
165
|
+
bitmap.fill(BACKGROUND);
|
|
166
|
+
const mark = height * 0.3;
|
|
167
|
+
drawBars(bitmap, (width - mark) / 2, height * 0.42, mark, mark - height * 0.02, mark * 1.8);
|
|
168
|
+
const line = "THIS LIVE STREAM HAS ENDED";
|
|
169
|
+
const cell = Math.floor(Math.min((width * 0.86) / (line.length * 6), height / 40));
|
|
170
|
+
drawText(bitmap, line, (width - textWidth(line, cell)) / 2, height * 0.56, cell, GREEN);
|
|
171
|
+
const small = Math.max(2, Math.floor(cell * 0.6));
|
|
172
|
+
const site = "NIXAMP.COM";
|
|
173
|
+
drawText(bitmap, site, (width - textWidth(site, small)) / 2, height * 0.56 + cell * 7 + small * 3, small, MUTED);
|
|
174
|
+
return bitmap;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The encode a channel copies an outro through: what a channel sends on the wire. */
|
|
178
|
+
export const OUTRO_ENCODE: Record<"audio" | "video", string[]> = {
|
|
179
|
+
video: ["-c", "copy", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"],
|
|
180
|
+
audio: ["-c", "copy", "-f", "mp3"],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export interface OutroOptions {
|
|
184
|
+
ffmpeg: string[];
|
|
185
|
+
/** Where the clips are kept. */
|
|
186
|
+
dir: string;
|
|
187
|
+
onEvent?: (message: string) => void;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The clips, drawn on first use and kept. `clip(kind)` answers the path,
|
|
192
|
+
* or null when there is no ffmpeg to make one, in which case a channel
|
|
193
|
+
* that ends simply closes as it used to.
|
|
194
|
+
*/
|
|
195
|
+
export class Outro {
|
|
196
|
+
private made = new Map<string, Promise<string | null>>();
|
|
197
|
+
|
|
198
|
+
constructor(private readonly options: OutroOptions) {}
|
|
199
|
+
|
|
200
|
+
clip(kind: "audio" | "video"): Promise<string | null> {
|
|
201
|
+
let pending = this.made.get(kind);
|
|
202
|
+
if (!pending) {
|
|
203
|
+
pending = Promise.resolve().then(() => this.make(kind)).catch(() => null);
|
|
204
|
+
this.made.set(kind, pending);
|
|
205
|
+
// A clip that could not be made is tried again next time, not remembered.
|
|
206
|
+
void pending.then((path) => { if (path === null) this.made.delete(kind); });
|
|
207
|
+
}
|
|
208
|
+
return pending;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private make(kind: "audio" | "video"): string | null {
|
|
212
|
+
mkdirSync(this.options.dir, { recursive: true });
|
|
213
|
+
const path = join(this.options.dir, kind === "video" ? `outro-v${OUTRO_VERSION}.mp4` : `outro-v${OUTRO_VERSION}.mp3`);
|
|
214
|
+
if (existsSync(path) && statSync(path).size > 1000) return path;
|
|
215
|
+
const [command, ...prefix] = this.options.ffmpeg;
|
|
216
|
+
if (!command) return null;
|
|
217
|
+
const args: string[] = ["-hide_banner", "-loglevel", "error", "-y"];
|
|
218
|
+
if (kind === "video") {
|
|
219
|
+
const picture = join(this.options.dir, `outro-v${OUTRO_VERSION}.png`);
|
|
220
|
+
writeFileSync(picture, encodePng(drawOutro()));
|
|
221
|
+
args.push(
|
|
222
|
+
"-loop", "1", "-framerate", "30", "-i", picture,
|
|
223
|
+
"-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo",
|
|
224
|
+
"-t", String(OUTRO_SECONDS),
|
|
225
|
+
"-c:v", "libx264", "-preset", "veryfast", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-r", "30", "-g", "30", "-keyint_min", "30",
|
|
226
|
+
"-c:a", "aac", "-b:a", "64k", "-shortest", "-movflags", "+faststart",
|
|
227
|
+
path,
|
|
228
|
+
);
|
|
229
|
+
} else {
|
|
230
|
+
// Two soft notes, then quiet: something to hear on a channel with no picture.
|
|
231
|
+
args.push(
|
|
232
|
+
"-f", "lavfi", "-i",
|
|
233
|
+
`aevalsrc=0.18*sin(2*PI*523.25*t)*exp(-2.5*t)+0.18*sin(2*PI*392*t)*exp(-2.5*max(t-0.7\\,0))*gte(t\\,0.7):s=44100:d=${OUTRO_SECONDS}`,
|
|
234
|
+
"-c:a", "libmp3lame", "-b:a", "128k", path,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const run = spawnSync(command, [...prefix, ...args], { timeout: 60_000 });
|
|
238
|
+
if (run.error || run.status !== 0 || !existsSync(path)) {
|
|
239
|
+
this.options.onEvent?.(` the outro could not be made${run.stderr ? `: ${run.stderr.toString("utf8").trim().split("\n").pop() ?? ""}` : ""}`);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
this.options.onEvent?.(` outro drawn: ${path}`);
|
|
243
|
+
return path;
|
|
244
|
+
}
|
|
245
|
+
}
|
package/src/partyline.ts
CHANGED
|
@@ -708,7 +708,7 @@ export class PartyLine {
|
|
|
708
708
|
* voice of whoever typed them. False when nobody is on the phone there,
|
|
709
709
|
* which is most of the time and costs nothing.
|
|
710
710
|
*/
|
|
711
|
-
async say(code: string, text: string, voice: string): Promise<boolean> {
|
|
711
|
+
async say(code: string, text: string, voice: string, settings?: { api_key_ref: string }): Promise<boolean> {
|
|
712
712
|
const room = this.rooms.get(code);
|
|
713
713
|
if (!room || room.conferenceId === null || room.callers === 0) return false;
|
|
714
714
|
const said = text.replace(/\s+/g, " ").trim().slice(0, 500);
|
|
@@ -716,6 +716,8 @@ export class PartyLine {
|
|
|
716
716
|
const answer = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
|
|
717
717
|
payload: said,
|
|
718
718
|
voice: voice || this.voice,
|
|
719
|
+
// An ElevenLabs voice needs the key Telnyx holds for it, by name.
|
|
720
|
+
...(settings ? { voice_settings: settings } : {}),
|
|
719
721
|
});
|
|
720
722
|
return answer !== null;
|
|
721
723
|
}
|
package/src/profile.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp profile` and `nixamp voices` -- who the rooms know you as, from
|
|
3
|
+
* the terminal, and the voices a line can be read in.
|
|
4
|
+
*
|
|
5
|
+
* The same route the Account panel uses (`/api/v1/me/handle`), signed in
|
|
6
|
+
* as this machine is. The MCP tools of the same names are these functions
|
|
7
|
+
* with a different front.
|
|
8
|
+
*/
|
|
9
|
+
import { readSession, type Session } from "./session.ts";
|
|
10
|
+
|
|
11
|
+
const HELP = `nixamp profile — who the rooms know you as.
|
|
12
|
+
|
|
13
|
+
nixamp profile your handle, voice and OpenProfile
|
|
14
|
+
nixamp profile --handle chovy the name on every line you say
|
|
15
|
+
nixamp profile --voice female the voice your lines are read in on the phone:
|
|
16
|
+
female, male, any, or a voice id (see \`nixamp voices\`)
|
|
17
|
+
nixamp profile --profile URL your OpenProfile.md; its Voice, Gender or Pronouns
|
|
18
|
+
decide the voice when you set none here
|
|
19
|
+
nixamp profile --json as JSON
|
|
20
|
+
nixamp voices the voices this nixamp.com reads lines in
|
|
21
|
+
|
|
22
|
+
When somebody is on the phone in a live room, every trollbox line is read
|
|
23
|
+
to them in the author's voice: the one set here, else the OpenProfile's,
|
|
24
|
+
else one picked for the account and kept. Two people in a room are two
|
|
25
|
+
different voices.
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
export interface Persona {
|
|
29
|
+
handle: string;
|
|
30
|
+
voice: string;
|
|
31
|
+
profile: string;
|
|
32
|
+
chosen: boolean;
|
|
33
|
+
/** The voice a line of theirs would be read in right now. */
|
|
34
|
+
spoken?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Pools {
|
|
38
|
+
provider: string;
|
|
39
|
+
female: string[];
|
|
40
|
+
male: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type Answer<T> = { ok: true; body: T } | { ok: false; status: number; error: string };
|
|
44
|
+
|
|
45
|
+
function headers(session: Pick<Session, "token">): Record<string, string> {
|
|
46
|
+
return { authorization: `Bearer ${session.token}`, "content-type": "application/json" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function readPersona(session: Pick<Session, "site" | "token">, fetcher: typeof fetch = fetch): Promise<Answer<Persona>> {
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetcher(`${session.site.replace(/\/+$/, "")}/api/v1/me/handle`, { headers: headers(session) });
|
|
52
|
+
const body = (await response.json().catch(() => ({}))) as Persona & { error?: string };
|
|
53
|
+
if (!response.ok) return { ok: false, status: response.status, error: body.error ?? `nixamp answered ${response.status}` };
|
|
54
|
+
return { ok: true, body };
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return { ok: false, status: 0, error: `could not reach ${session.site}: ${(error as Error).message}` };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function writePersona(
|
|
61
|
+
session: Pick<Session, "site" | "token">,
|
|
62
|
+
wanted: { handle?: string; voice?: string; profile?: string },
|
|
63
|
+
fetcher: typeof fetch = fetch,
|
|
64
|
+
): Promise<Answer<Persona>> {
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetcher(`${session.site.replace(/\/+$/, "")}/api/v1/me/handle`, {
|
|
67
|
+
method: "PUT",
|
|
68
|
+
headers: headers(session),
|
|
69
|
+
body: JSON.stringify(wanted),
|
|
70
|
+
});
|
|
71
|
+
const body = (await response.json().catch(() => ({}))) as Persona & { error?: string };
|
|
72
|
+
if (!response.ok) return { ok: false, status: response.status, error: body.error ?? `nixamp answered ${response.status}` };
|
|
73
|
+
return { ok: true, body };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { ok: false, status: 0, error: `could not reach ${session.site}: ${(error as Error).message}` };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function readVoices(session: Pick<Session, "site" | "token">, fetcher: typeof fetch = fetch): Promise<Answer<Pools>> {
|
|
80
|
+
try {
|
|
81
|
+
const response = await fetcher(`${session.site.replace(/\/+$/, "")}/api/v1/voices`, { headers: headers(session) });
|
|
82
|
+
const body = (await response.json().catch(() => ({}))) as Pools & { error?: string };
|
|
83
|
+
if (!response.ok) return { ok: false, status: response.status, error: body.error ?? `nixamp answered ${response.status}` };
|
|
84
|
+
return { ok: true, body };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return { ok: false, status: 0, error: `could not reach ${session.site}: ${(error as Error).message}` };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A persona, as the terminal says it. */
|
|
91
|
+
export function personaLines(persona: Persona): string[] {
|
|
92
|
+
const voice = persona.voice === "female" ? "a woman's voice"
|
|
93
|
+
: persona.voice === "male" ? "a man's voice"
|
|
94
|
+
: persona.voice ? `the voice ${persona.voice}`
|
|
95
|
+
: persona.profile ? "whatever your OpenProfile says, else one picked for you"
|
|
96
|
+
: "one picked for you";
|
|
97
|
+
return [
|
|
98
|
+
persona.chosen ? `You are ${persona.handle} in every room.` : `Rooms call you ${persona.handle} until you pick a handle (--handle).`,
|
|
99
|
+
`On the phone your lines are read in ${voice}${persona.spoken ? ` (${persona.spoken})` : ""}.`,
|
|
100
|
+
persona.profile ? `OpenProfile: ${persona.profile}` : "No OpenProfile set (--profile URL).",
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function flag(argv: string[], name: string): string | undefined {
|
|
105
|
+
const at = argv.indexOf(name);
|
|
106
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ProfileDeps {
|
|
110
|
+
fetcher?: typeof fetch;
|
|
111
|
+
session?: Pick<Session, "site" | "token"> | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function profile(argv: string[], deps: ProfileDeps = {}): Promise<number> {
|
|
115
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
116
|
+
console.log(HELP);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
const session = deps.session === undefined ? readSession() : deps.session;
|
|
120
|
+
if (session === null) {
|
|
121
|
+
console.error("nixamp: not signed in. Try `nixamp login`.");
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
const fetcher = deps.fetcher ?? fetch;
|
|
125
|
+
const wanted: { handle?: string; voice?: string; profile?: string } = {};
|
|
126
|
+
for (const [name, key] of [["--handle", "handle"], ["--voice", "voice"], ["--profile", "profile"]] as const) {
|
|
127
|
+
if (argv.includes(name)) {
|
|
128
|
+
const value = flag(argv, name);
|
|
129
|
+
if (value === undefined || value.startsWith("--")) {
|
|
130
|
+
console.error(`nixamp: ${name} needs a value.`);
|
|
131
|
+
return 64;
|
|
132
|
+
}
|
|
133
|
+
wanted[key] = value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const answer = Object.keys(wanted).length > 0 ? await writePersona(session, wanted, fetcher) : await readPersona(session, fetcher);
|
|
137
|
+
if (!answer.ok) {
|
|
138
|
+
console.error(`nixamp: ${answer.error}`);
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
if (argv.includes("--json")) {
|
|
142
|
+
console.log(JSON.stringify(answer.body, null, 2));
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
for (const line of personaLines(answer.body)) console.log(line);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function voices(argv: string[], deps: ProfileDeps = {}): Promise<number> {
|
|
150
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
151
|
+
console.log(HELP);
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
const session = deps.session === undefined ? readSession() : deps.session;
|
|
155
|
+
if (session === null) {
|
|
156
|
+
console.error("nixamp: not signed in. Try `nixamp login`.");
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
const answer = await readVoices(session, deps.fetcher ?? fetch);
|
|
160
|
+
if (!answer.ok) {
|
|
161
|
+
console.error(`nixamp: ${answer.error}`);
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
if (argv.includes("--json")) {
|
|
165
|
+
console.log(JSON.stringify(answer.body, null, 2));
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
console.log(`Voices: ${answer.body.provider}. Set one with \`nixamp profile --voice <id>\`, or --voice female/male to be given one of these and keep it.`);
|
|
169
|
+
console.log(" Women:");
|
|
170
|
+
for (const one of answer.body.female) console.log(` ${one}`);
|
|
171
|
+
console.log(" Men:");
|
|
172
|
+
for (const one of answer.body.male) console.log(` ${one}`);
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
package/src/rtmp-in.ts
CHANGED
|
@@ -79,10 +79,13 @@ export class RtmpListeners {
|
|
|
79
79
|
child.on("close", () => this.done(slot, child, channel));
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
private done(slot: RtmpSlot, child: ChildProcess, channel: { close(): void } | null): void {
|
|
82
|
+
private done(slot: RtmpSlot, child: ChildProcess, channel: { close(): void; finish?(): void } | null): void {
|
|
83
83
|
if (this.running.get(slot.port) !== child) return;
|
|
84
84
|
this.running.delete(slot.port);
|
|
85
|
-
|
|
85
|
+
// The publisher went: the outro plays for whoever is still there, and
|
|
86
|
+
// whoever joins in the next hour, before the channel closes.
|
|
87
|
+
if (channel?.finish) channel.finish();
|
|
88
|
+
else channel?.close();
|
|
86
89
|
if (!this.stopped) this.arm(slot);
|
|
87
90
|
}
|
|
88
91
|
|
package/src/server.ts
CHANGED
|
@@ -88,7 +88,8 @@ import { Rooms } from "./rooms.ts";
|
|
|
88
88
|
import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.ts";
|
|
89
89
|
import { MAX_BYTES as SPEECH_BYTES, Speech, SpeechError, isWav, languageOf } from "./speech.ts";
|
|
90
90
|
import { Captions } from "./captions.ts";
|
|
91
|
-
import {
|
|
91
|
+
import { Outro } from "./outro.ts";
|
|
92
|
+
import { Profiles, Voices, spokenLine, spokenVoiceFor } from "./voices.ts";
|
|
92
93
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
|
|
93
94
|
import {
|
|
94
95
|
applyRemoteConfig,
|
|
@@ -1736,6 +1737,8 @@ export interface HandlerOptions {
|
|
|
1736
1737
|
speech?: Speech;
|
|
1737
1738
|
/** Other people's OpenProfiles, for the voice their lines are read in. */
|
|
1738
1739
|
profiles?: Profiles;
|
|
1740
|
+
/** The voices lines are read in: ElevenLabs when Telnyx holds the key, Kokoro otherwise. */
|
|
1741
|
+
voices?: Voices;
|
|
1739
1742
|
/** Tickets: a paid pass to one event's room. Absent means every show is free. */
|
|
1740
1743
|
tickets?: Tickets;
|
|
1741
1744
|
/**
|
|
@@ -2728,11 +2731,28 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2728
2731
|
void (async () => {
|
|
2729
2732
|
const persona = (await options.handles?.persona(authorId)) ?? { handle, voice: "", profile: "" };
|
|
2730
2733
|
const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
|
|
2731
|
-
const
|
|
2732
|
-
|
|
2734
|
+
const pools = options.voices ? await options.voices.pools() : { provider: "kokoro" as const, female: [], male: [] };
|
|
2735
|
+
const spoken = spokenVoiceFor({ userId: authorId, voice: persona.voice, profile }, pools);
|
|
2736
|
+
await partyLine.say(code, spokenLine(handle, body), spoken.voice, spoken.settings);
|
|
2733
2737
|
})().catch(() => undefined);
|
|
2734
2738
|
};
|
|
2735
2739
|
|
|
2740
|
+
/*
|
|
2741
|
+
* The voices a line can be read in, for whoever wants to pick one by
|
|
2742
|
+
* id: which provider, and the women's and the men's pools. Signed in,
|
|
2743
|
+
* like the profile it feeds.
|
|
2744
|
+
*/
|
|
2745
|
+
if (path === "/api/v1/voices" && options.accounts) {
|
|
2746
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
2747
|
+
if (who === null) {
|
|
2748
|
+
json(response, 401, { error: "not signed in" });
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
const pools = options.voices ? await options.voices.pools() : { provider: "kokoro" as const, female: [], male: [] };
|
|
2752
|
+
json(response, 200, { provider: pools.provider, female: pools.female, male: pools.male });
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2736
2756
|
/*
|
|
2737
2757
|
* The trollbox: the chat for one live room, keyed by the server and
|
|
2738
2758
|
* the channel so every viewer of a stream is in the same box whichever
|
|
@@ -2878,7 +2898,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2878
2898
|
// be sent alone; what is not sent is kept.
|
|
2879
2899
|
if (request.method === "GET") {
|
|
2880
2900
|
const persona = await handles.persona(who.id);
|
|
2881
|
-
|
|
2901
|
+
// And the voice a line of theirs would be read in right now, so the
|
|
2902
|
+
// panel and the CLI can say it rather than describe the rule.
|
|
2903
|
+
const pools = options.voices ? await options.voices.pools() : null;
|
|
2904
|
+
const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
|
|
2905
|
+
const spoken = pools ? spokenVoiceFor({ userId: who.id, voice: persona.voice, profile }, pools).voice : "";
|
|
2906
|
+
json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "", spoken });
|
|
2882
2907
|
return;
|
|
2883
2908
|
}
|
|
2884
2909
|
if (request.method === "PUT" || request.method === "POST") {
|
|
@@ -3370,6 +3395,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3370
3395
|
// A picture of it, and a line about it, where there is one.
|
|
3371
3396
|
art: artOf(one),
|
|
3372
3397
|
about: one.about ?? "",
|
|
3398
|
+
// Over, with the outro playing: the page says so rather than "LIVE".
|
|
3399
|
+
...(one.ended ? { ended: one.ended } : {}),
|
|
3373
3400
|
})),
|
|
3374
3401
|
// Anything re-streamed into this server is a live stream too, and was
|
|
3375
3402
|
// sitting in the middle of the playlist among the files -- which is
|
|
@@ -5330,8 +5357,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
5330
5357
|
|
|
5331
5358
|
// The pictures of channels, taken once each and kept while the channel is on.
|
|
5332
5359
|
const art = new ArtCache();
|
|
5360
|
+
// What a channel plays once its show is over: drawn here, once, by the
|
|
5361
|
+
// same ffmpeg that carries the channels. No ffmpeg, no outro, and a show
|
|
5362
|
+
// that ends closes its channel as before.
|
|
5363
|
+
const outro = new Outro({ ffmpeg: tools.ffmpeg, dir: join(stateDir(), "outro"), onEvent: (message) => console.log(message) });
|
|
5333
5364
|
const channels = new Channels({
|
|
5334
5365
|
ffmpeg: tools.ffmpeg,
|
|
5366
|
+
...(tools.carries ? { outro: (kind) => outro.clip(kind) } : {}),
|
|
5367
|
+
onOutro: (info) => console.log(` "${info.id}" has ended; the outro plays for an hour.`),
|
|
5335
5368
|
onStart: (info) =>
|
|
5336
5369
|
console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
|
|
5337
5370
|
onEnd: (info) => {
|
|
@@ -5870,8 +5903,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
5870
5903
|
...(rooms ? { rooms } : {}),
|
|
5871
5904
|
...(trollbox ? { trollbox } : {}),
|
|
5872
5905
|
...(speech ? { speech } : {}),
|
|
5873
|
-
// Other people's OpenProfiles, read for the voice a line is spoken in
|
|
5906
|
+
// Other people's OpenProfiles, read for the voice a line is spoken in,
|
|
5907
|
+
// and the voices to speak in: ElevenLabs when the Telnyx account holds
|
|
5908
|
+
// the key for it (an integration secret named "elevenlabs"), Kokoro
|
|
5909
|
+
// otherwise. Nothing to configure on the deployment for either.
|
|
5874
5910
|
...(accounts ? { profiles: new Profiles() } : {}),
|
|
5911
|
+
...(accounts && process.env["TELNYX_API_KEY"]
|
|
5912
|
+
? { voices: new Voices({ telnyxApiKey: process.env["TELNYX_API_KEY"], onEvent: (message) => console.log(message) }) }
|
|
5913
|
+
: {}),
|
|
5875
5914
|
...(tickets ? { tickets } : {}),
|
|
5876
5915
|
...(authServer ? { authServer } : {}),
|
|
5877
5916
|
...(parties ? { parties } : {}),
|