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/dist/outro.js ADDED
@@ -0,0 +1,225 @@
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
+ /** How long the outro plays after a show ends, before the channel closes. */
22
+ export const OUTRO_MS = 60 * 60 * 1000;
23
+ /** The clip's length; it loops. */
24
+ export const OUTRO_SECONDS = 5;
25
+ /** Bumped when the drawing changes, so an old clip on disk is drawn again. */
26
+ export const OUTRO_VERSION = 1;
27
+ const BACKGROUND = { r: 8, g: 12, b: 9, a: 255 };
28
+ const GREEN = { r: 74, g: 246, b: 137, a: 255 };
29
+ const DIM = { r: 34, g: 122, b: 74, a: 255 };
30
+ const MUTED = { r: 120, g: 150, b: 130, a: 255 };
31
+ const BARS = [0.30, 0.55, 0.85, 0.62, 1.0, 0.72, 0.45, 0.25];
32
+ /** A canvas of straight RGBA bytes. */
33
+ export class Bitmap {
34
+ width;
35
+ height;
36
+ pixels;
37
+ constructor(width, height) {
38
+ this.width = width;
39
+ this.height = height;
40
+ this.pixels = new Uint8Array(width * height * 4);
41
+ }
42
+ fill(color) {
43
+ for (let i = 0; i < this.pixels.length; i += 4)
44
+ this.set(i, color);
45
+ }
46
+ rect(x, y, w, h, color) {
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++)
53
+ this.set((py * this.width + px) * 4, color);
54
+ }
55
+ }
56
+ set(offset, { r, g, b, a }) {
57
+ this.pixels[offset] = r;
58
+ this.pixels[offset + 1] = g;
59
+ this.pixels[offset + 2] = b;
60
+ this.pixels[offset + 3] = a;
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++)
68
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
69
+ table[n] = c >>> 0;
70
+ }
71
+ return table;
72
+ })();
73
+ function crc32(bytes) {
74
+ let c = 0xffffffff;
75
+ for (const byte of bytes)
76
+ c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
77
+ return (c ^ 0xffffffff) >>> 0;
78
+ }
79
+ function chunk(type, data) {
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
+ /** Encode as a non-interlaced 8-bit RGBA PNG. */
89
+ export function encodePng(bitmap) {
90
+ const { width, height, pixels } = bitmap;
91
+ const stride = width * 4;
92
+ const raw = Buffer.alloc((stride + 1) * height);
93
+ for (let y = 0; y < height; y++) {
94
+ raw[y * (stride + 1)] = 0;
95
+ Buffer.from(pixels.subarray(y * stride, (y + 1) * stride)).copy(raw, y * (stride + 1) + 1);
96
+ }
97
+ const ihdr = Buffer.alloc(13);
98
+ ihdr.writeUInt32BE(width, 0);
99
+ ihdr.writeUInt32BE(height, 4);
100
+ ihdr[8] = 8;
101
+ ihdr[9] = 6;
102
+ return Buffer.concat([
103
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
104
+ chunk("IHDR", ihdr),
105
+ chunk("IDAT", deflateSync(raw, { level: 9 })),
106
+ chunk("IEND", new Uint8Array(0)),
107
+ ]);
108
+ }
109
+ /** A 5x7 pixel face: the letters the outro needs and nothing else. */
110
+ const GLYPHS = {
111
+ A: [".XXX.", "X...X", "X...X", "XXXXX", "X...X", "X...X", "X...X"],
112
+ C: [".XXXX", "X....", "X....", "X....", "X....", "X....", ".XXXX"],
113
+ D: ["XXXX.", "X...X", "X...X", "X...X", "X...X", "X...X", "XXXX."],
114
+ E: ["XXXXX", "X....", "X....", "XXXX.", "X....", "X....", "XXXXX"],
115
+ H: ["X...X", "X...X", "X...X", "XXXXX", "X...X", "X...X", "X...X"],
116
+ I: ["XXXXX", "..X..", "..X..", "..X..", "..X..", "..X..", "XXXXX"],
117
+ L: ["X....", "X....", "X....", "X....", "X....", "X....", "XXXXX"],
118
+ M: ["X...X", "XX.XX", "X.X.X", "X.X.X", "X...X", "X...X", "X...X"],
119
+ N: ["X...X", "XX..X", "X.X.X", "X..XX", "X...X", "X...X", "X...X"],
120
+ O: [".XXX.", "X...X", "X...X", "X...X", "X...X", "X...X", ".XXX."],
121
+ P: ["XXXX.", "X...X", "X...X", "XXXX.", "X....", "X....", "X...."],
122
+ R: ["XXXX.", "X...X", "X...X", "XXXX.", "X.X..", "X..X.", "X...X"],
123
+ S: [".XXXX", "X....", "X....", ".XXX.", "....X", "....X", "XXXX."],
124
+ T: ["XXXXX", "..X..", "..X..", "..X..", "..X..", "..X..", "..X.."],
125
+ V: ["X...X", "X...X", "X...X", "X...X", "X...X", ".X.X.", "..X.."],
126
+ X: ["X...X", "X...X", ".X.X.", "..X..", ".X.X.", "X...X", "X...X"],
127
+ ".": [".....", ".....", ".....", ".....", ".....", "..XX.", "..XX."],
128
+ };
129
+ export function textWidth(text, cell) {
130
+ return text.length * 6 * cell - cell;
131
+ }
132
+ export function drawText(bitmap, text, x, y, cell, color) {
133
+ let at = x;
134
+ for (const letter of text) {
135
+ const rows = GLYPHS[letter];
136
+ if (rows) {
137
+ rows.forEach((row, r) => {
138
+ for (let c = 0; c < row.length; c++)
139
+ if (row[c] === "X")
140
+ bitmap.rect(at + c * cell, y + r * cell, cell, cell, color);
141
+ });
142
+ }
143
+ at += 6 * cell;
144
+ }
145
+ }
146
+ function drawBars(bitmap, left, bottom, width, tallest, scale) {
147
+ const slot = width / BARS.length;
148
+ const bar = slot * 0.62;
149
+ BARS.forEach((value, i) => {
150
+ const height = Math.max(2, tallest * value);
151
+ const x = left + i * slot + (slot - bar) / 2;
152
+ bitmap.rect(x, bottom - height, bar, height, GREEN);
153
+ const peak = Math.max(2, scale * 0.018);
154
+ bitmap.rect(x, bottom - height - peak * 2.4, bar, peak, DIM);
155
+ });
156
+ bitmap.rect(left, bottom, width, Math.max(2, scale * 0.028), DIM);
157
+ }
158
+ /** The picture: the mark above the words, on the plate. 1280x720. */
159
+ export function drawOutro(width = 1280, height = 720) {
160
+ const bitmap = new Bitmap(width, height);
161
+ bitmap.fill(BACKGROUND);
162
+ const mark = height * 0.3;
163
+ drawBars(bitmap, (width - mark) / 2, height * 0.42, mark, mark - height * 0.02, mark * 1.8);
164
+ const line = "THIS LIVE STREAM HAS ENDED";
165
+ const cell = Math.floor(Math.min((width * 0.86) / (line.length * 6), height / 40));
166
+ drawText(bitmap, line, (width - textWidth(line, cell)) / 2, height * 0.56, cell, GREEN);
167
+ const small = Math.max(2, Math.floor(cell * 0.6));
168
+ const site = "NIXAMP.COM";
169
+ drawText(bitmap, site, (width - textWidth(site, small)) / 2, height * 0.56 + cell * 7 + small * 3, small, MUTED);
170
+ return bitmap;
171
+ }
172
+ /** The encode a channel copies an outro through: what a channel sends on the wire. */
173
+ export const OUTRO_ENCODE = {
174
+ video: ["-c", "copy", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"],
175
+ audio: ["-c", "copy", "-f", "mp3"],
176
+ };
177
+ /**
178
+ * The clips, drawn on first use and kept. `clip(kind)` answers the path,
179
+ * or null when there is no ffmpeg to make one, in which case a channel
180
+ * that ends simply closes as it used to.
181
+ */
182
+ export class Outro {
183
+ options;
184
+ made = new Map();
185
+ constructor(options) {
186
+ this.options = options;
187
+ }
188
+ clip(kind) {
189
+ let pending = this.made.get(kind);
190
+ if (!pending) {
191
+ pending = Promise.resolve().then(() => this.make(kind)).catch(() => null);
192
+ this.made.set(kind, pending);
193
+ // A clip that could not be made is tried again next time, not remembered.
194
+ void pending.then((path) => { if (path === null)
195
+ this.made.delete(kind); });
196
+ }
197
+ return pending;
198
+ }
199
+ make(kind) {
200
+ mkdirSync(this.options.dir, { recursive: true });
201
+ const path = join(this.options.dir, kind === "video" ? `outro-v${OUTRO_VERSION}.mp4` : `outro-v${OUTRO_VERSION}.mp3`);
202
+ if (existsSync(path) && statSync(path).size > 1000)
203
+ return path;
204
+ const [command, ...prefix] = this.options.ffmpeg;
205
+ if (!command)
206
+ return null;
207
+ const args = ["-hide_banner", "-loglevel", "error", "-y"];
208
+ if (kind === "video") {
209
+ const picture = join(this.options.dir, `outro-v${OUTRO_VERSION}.png`);
210
+ writeFileSync(picture, encodePng(drawOutro()));
211
+ args.push("-loop", "1", "-framerate", "30", "-i", picture, "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-t", String(OUTRO_SECONDS), "-c:v", "libx264", "-preset", "veryfast", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-r", "30", "-g", "30", "-keyint_min", "30", "-c:a", "aac", "-b:a", "64k", "-shortest", "-movflags", "+faststart", path);
212
+ }
213
+ else {
214
+ // Two soft notes, then quiet: something to hear on a channel with no picture.
215
+ args.push("-f", "lavfi", "-i", `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}`, "-c:a", "libmp3lame", "-b:a", "128k", path);
216
+ }
217
+ const run = spawnSync(command, [...prefix, ...args], { timeout: 60_000 });
218
+ if (run.error || run.status !== 0 || !existsSync(path)) {
219
+ this.options.onEvent?.(` the outro could not be made${run.stderr ? `: ${run.stderr.toString("utf8").trim().split("\n").pop() ?? ""}` : ""}`);
220
+ return null;
221
+ }
222
+ this.options.onEvent?.(` outro drawn: ${path}`);
223
+ return path;
224
+ }
225
+ }
@@ -261,7 +261,9 @@ export declare class PartyLine {
261
261
  * voice of whoever typed them. False when nobody is on the phone there,
262
262
  * which is most of the time and costs nothing.
263
263
  */
264
- say(code: string, text: string, voice: string): Promise<boolean>;
264
+ say(code: string, text: string, voice: string, settings?: {
265
+ api_key_ref: string;
266
+ }): Promise<boolean>;
265
267
  /** Whether anybody is on the phone in a room, before a line is worth reading. */
266
268
  hasCallers(code: string): boolean;
267
269
  /**
package/dist/partyline.js CHANGED
@@ -589,7 +589,7 @@ export class PartyLine {
589
589
  * voice of whoever typed them. False when nobody is on the phone there,
590
590
  * which is most of the time and costs nothing.
591
591
  */
592
- async say(code, text, voice) {
592
+ async say(code, text, voice, settings) {
593
593
  const room = this.rooms.get(code);
594
594
  if (!room || room.conferenceId === null || room.callers === 0)
595
595
  return false;
@@ -599,6 +599,8 @@ export class PartyLine {
599
599
  const answer = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
600
600
  payload: said,
601
601
  voice: voice || this.voice,
602
+ // An ElevenLabs voice needs the key Telnyx holds for it, by name.
603
+ ...(settings ? { voice_settings: settings } : {}),
602
604
  });
603
605
  return answer !== null;
604
606
  }
@@ -0,0 +1,45 @@
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 { type Session } from "./session.ts";
10
+ export interface Persona {
11
+ handle: string;
12
+ voice: string;
13
+ profile: string;
14
+ chosen: boolean;
15
+ /** The voice a line of theirs would be read in right now. */
16
+ spoken?: string;
17
+ }
18
+ export interface Pools {
19
+ provider: string;
20
+ female: string[];
21
+ male: string[];
22
+ }
23
+ export type Answer<T> = {
24
+ ok: true;
25
+ body: T;
26
+ } | {
27
+ ok: false;
28
+ status: number;
29
+ error: string;
30
+ };
31
+ export declare function readPersona(session: Pick<Session, "site" | "token">, fetcher?: typeof fetch): Promise<Answer<Persona>>;
32
+ export declare function writePersona(session: Pick<Session, "site" | "token">, wanted: {
33
+ handle?: string;
34
+ voice?: string;
35
+ profile?: string;
36
+ }, fetcher?: typeof fetch): Promise<Answer<Persona>>;
37
+ export declare function readVoices(session: Pick<Session, "site" | "token">, fetcher?: typeof fetch): Promise<Answer<Pools>>;
38
+ /** A persona, as the terminal says it. */
39
+ export declare function personaLines(persona: Persona): string[];
40
+ export interface ProfileDeps {
41
+ fetcher?: typeof fetch;
42
+ session?: Pick<Session, "site" | "token"> | null;
43
+ }
44
+ export declare function profile(argv: string[], deps?: ProfileDeps): Promise<number>;
45
+ export declare function voices(argv: string[], deps?: ProfileDeps): Promise<number>;
@@ -0,0 +1,148 @@
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 } from "./session.js";
10
+ const HELP = `nixamp profile — who the rooms know you as.
11
+
12
+ nixamp profile your handle, voice and OpenProfile
13
+ nixamp profile --handle chovy the name on every line you say
14
+ nixamp profile --voice female the voice your lines are read in on the phone:
15
+ female, male, any, or a voice id (see \`nixamp voices\`)
16
+ nixamp profile --profile URL your OpenProfile.md; its Voice, Gender or Pronouns
17
+ decide the voice when you set none here
18
+ nixamp profile --json as JSON
19
+ nixamp voices the voices this nixamp.com reads lines in
20
+
21
+ When somebody is on the phone in a live room, every trollbox line is read
22
+ to them in the author's voice: the one set here, else the OpenProfile's,
23
+ else one picked for the account and kept. Two people in a room are two
24
+ different voices.
25
+ `;
26
+ function headers(session) {
27
+ return { authorization: `Bearer ${session.token}`, "content-type": "application/json" };
28
+ }
29
+ export async function readPersona(session, fetcher = fetch) {
30
+ try {
31
+ const response = await fetcher(`${session.site.replace(/\/+$/, "")}/api/v1/me/handle`, { headers: headers(session) });
32
+ const body = (await response.json().catch(() => ({})));
33
+ if (!response.ok)
34
+ return { ok: false, status: response.status, error: body.error ?? `nixamp answered ${response.status}` };
35
+ return { ok: true, body };
36
+ }
37
+ catch (error) {
38
+ return { ok: false, status: 0, error: `could not reach ${session.site}: ${error.message}` };
39
+ }
40
+ }
41
+ export async function writePersona(session, wanted, fetcher = fetch) {
42
+ try {
43
+ const response = await fetcher(`${session.site.replace(/\/+$/, "")}/api/v1/me/handle`, {
44
+ method: "PUT",
45
+ headers: headers(session),
46
+ body: JSON.stringify(wanted),
47
+ });
48
+ const body = (await response.json().catch(() => ({})));
49
+ if (!response.ok)
50
+ return { ok: false, status: response.status, error: body.error ?? `nixamp answered ${response.status}` };
51
+ return { ok: true, body };
52
+ }
53
+ catch (error) {
54
+ return { ok: false, status: 0, error: `could not reach ${session.site}: ${error.message}` };
55
+ }
56
+ }
57
+ export async function readVoices(session, fetcher = fetch) {
58
+ try {
59
+ const response = await fetcher(`${session.site.replace(/\/+$/, "")}/api/v1/voices`, { headers: headers(session) });
60
+ const body = (await response.json().catch(() => ({})));
61
+ if (!response.ok)
62
+ return { ok: false, status: response.status, error: body.error ?? `nixamp answered ${response.status}` };
63
+ return { ok: true, body };
64
+ }
65
+ catch (error) {
66
+ return { ok: false, status: 0, error: `could not reach ${session.site}: ${error.message}` };
67
+ }
68
+ }
69
+ /** A persona, as the terminal says it. */
70
+ export function personaLines(persona) {
71
+ const voice = persona.voice === "female" ? "a woman's voice"
72
+ : persona.voice === "male" ? "a man's voice"
73
+ : persona.voice ? `the voice ${persona.voice}`
74
+ : persona.profile ? "whatever your OpenProfile says, else one picked for you"
75
+ : "one picked for you";
76
+ return [
77
+ persona.chosen ? `You are ${persona.handle} in every room.` : `Rooms call you ${persona.handle} until you pick a handle (--handle).`,
78
+ `On the phone your lines are read in ${voice}${persona.spoken ? ` (${persona.spoken})` : ""}.`,
79
+ persona.profile ? `OpenProfile: ${persona.profile}` : "No OpenProfile set (--profile URL).",
80
+ ];
81
+ }
82
+ function flag(argv, name) {
83
+ const at = argv.indexOf(name);
84
+ return at === -1 ? undefined : argv[at + 1];
85
+ }
86
+ export async function profile(argv, deps = {}) {
87
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
88
+ console.log(HELP);
89
+ return 0;
90
+ }
91
+ const session = deps.session === undefined ? readSession() : deps.session;
92
+ if (session === null) {
93
+ console.error("nixamp: not signed in. Try `nixamp login`.");
94
+ return 1;
95
+ }
96
+ const fetcher = deps.fetcher ?? fetch;
97
+ const wanted = {};
98
+ for (const [name, key] of [["--handle", "handle"], ["--voice", "voice"], ["--profile", "profile"]]) {
99
+ if (argv.includes(name)) {
100
+ const value = flag(argv, name);
101
+ if (value === undefined || value.startsWith("--")) {
102
+ console.error(`nixamp: ${name} needs a value.`);
103
+ return 64;
104
+ }
105
+ wanted[key] = value;
106
+ }
107
+ }
108
+ const answer = Object.keys(wanted).length > 0 ? await writePersona(session, wanted, fetcher) : await readPersona(session, fetcher);
109
+ if (!answer.ok) {
110
+ console.error(`nixamp: ${answer.error}`);
111
+ return 1;
112
+ }
113
+ if (argv.includes("--json")) {
114
+ console.log(JSON.stringify(answer.body, null, 2));
115
+ return 0;
116
+ }
117
+ for (const line of personaLines(answer.body))
118
+ console.log(line);
119
+ return 0;
120
+ }
121
+ export async function voices(argv, deps = {}) {
122
+ if (argv.includes("--help") || argv.includes("-h")) {
123
+ console.log(HELP);
124
+ return 0;
125
+ }
126
+ const session = deps.session === undefined ? readSession() : deps.session;
127
+ if (session === null) {
128
+ console.error("nixamp: not signed in. Try `nixamp login`.");
129
+ return 1;
130
+ }
131
+ const answer = await readVoices(session, deps.fetcher ?? fetch);
132
+ if (!answer.ok) {
133
+ console.error(`nixamp: ${answer.error}`);
134
+ return 1;
135
+ }
136
+ if (argv.includes("--json")) {
137
+ console.log(JSON.stringify(answer.body, null, 2));
138
+ return 0;
139
+ }
140
+ 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.`);
141
+ console.log(" Women:");
142
+ for (const one of answer.body.female)
143
+ console.log(` ${one}`);
144
+ console.log(" Men:");
145
+ for (const one of answer.body.male)
146
+ console.log(` ${one}`);
147
+ return 0;
148
+ }
package/dist/rtmp-in.js CHANGED
@@ -70,7 +70,12 @@ export class RtmpListeners {
70
70
  if (this.running.get(slot.port) !== child)
71
71
  return;
72
72
  this.running.delete(slot.port);
73
- channel?.close();
73
+ // The publisher went: the outro plays for whoever is still there, and
74
+ // whoever joins in the next hour, before the channel closes.
75
+ if (channel?.finish)
76
+ channel.finish();
77
+ else
78
+ channel?.close();
74
79
  if (!this.stopped)
75
80
  this.arm(slot);
76
81
  }
package/dist/server.d.ts CHANGED
@@ -32,7 +32,7 @@ import { Rooms } from "./rooms.ts";
32
32
  import { Trollbox } from "./trollbox.ts";
33
33
  import { Speech } from "./speech.ts";
34
34
  import { Captions } from "./captions.ts";
35
- import { Profiles } from "./voices.ts";
35
+ import { Profiles, Voices } from "./voices.ts";
36
36
  import { type Codecs } from "./audio.ts";
37
37
  import { type Tools, type Track } from "./audio.ts";
38
38
  import { type Command, type RemoteTrack, type Snapshot } from "./protocol.ts";
@@ -701,6 +701,8 @@ export interface HandlerOptions {
701
701
  speech?: Speech;
702
702
  /** Other people's OpenProfiles, for the voice their lines are read in. */
703
703
  profiles?: Profiles;
704
+ /** The voices lines are read in: ElevenLabs when Telnyx holds the key, Kokoro otherwise. */
705
+ voices?: Voices;
704
706
  /** Tickets: a paid pass to one event's room. Absent means every show is free. */
705
707
  tickets?: Tickets;
706
708
  /**
package/dist/server.js CHANGED
@@ -67,7 +67,8 @@ import { Rooms } from "./rooms.js";
67
67
  import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.js";
68
68
  import { MAX_BYTES as SPEECH_BYTES, Speech, SpeechError, isWav, languageOf } from "./speech.js";
69
69
  import { Captions } from "./captions.js";
70
- import { Profiles, spokenLine, telnyxVoiceFor, voicesFromEnv } from "./voices.js";
70
+ import { Outro } from "./outro.js";
71
+ import { Profiles, Voices, spokenLine, spokenVoiceFor } from "./voices.js";
71
72
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
72
73
  import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
73
74
  import { isRemote, isTransportStream, playsInBrowser, sourceLabel } from "./sources.js";
@@ -2212,10 +2213,26 @@ export function createHandler(engine, options) {
2212
2213
  void (async () => {
2213
2214
  const persona = (await options.handles?.persona(authorId)) ?? { handle, voice: "", profile: "" };
2214
2215
  const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
2215
- const voice = telnyxVoiceFor({ userId: authorId, voice: persona.voice, profile }, voicesFromEnv());
2216
- await partyLine.say(code, spokenLine(handle, body), voice);
2216
+ const pools = options.voices ? await options.voices.pools() : { provider: "kokoro", female: [], male: [] };
2217
+ const spoken = spokenVoiceFor({ userId: authorId, voice: persona.voice, profile }, pools);
2218
+ await partyLine.say(code, spokenLine(handle, body), spoken.voice, spoken.settings);
2217
2219
  })().catch(() => undefined);
2218
2220
  };
2221
+ /*
2222
+ * The voices a line can be read in, for whoever wants to pick one by
2223
+ * id: which provider, and the women's and the men's pools. Signed in,
2224
+ * like the profile it feeds.
2225
+ */
2226
+ if (path === "/api/v1/voices" && options.accounts) {
2227
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
2228
+ if (who === null) {
2229
+ json(response, 401, { error: "not signed in" });
2230
+ return;
2231
+ }
2232
+ const pools = options.voices ? await options.voices.pools() : { provider: "kokoro", female: [], male: [] };
2233
+ json(response, 200, { provider: pools.provider, female: pools.female, male: pools.male });
2234
+ return;
2235
+ }
2219
2236
  /*
2220
2237
  * The trollbox: the chat for one live room, keyed by the server and
2221
2238
  * the channel so every viewer of a stream is in the same box whichever
@@ -2366,7 +2383,12 @@ export function createHandler(engine, options) {
2366
2383
  // be sent alone; what is not sent is kept.
2367
2384
  if (request.method === "GET") {
2368
2385
  const persona = await handles.persona(who.id);
2369
- json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "" });
2386
+ // And the voice a line of theirs would be read in right now, so the
2387
+ // panel and the CLI can say it rather than describe the rule.
2388
+ const pools = options.voices ? await options.voices.pools() : null;
2389
+ const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
2390
+ const spoken = pools ? spokenVoiceFor({ userId: who.id, voice: persona.voice, profile }, pools).voice : "";
2391
+ json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "", spoken });
2370
2392
  return;
2371
2393
  }
2372
2394
  if (request.method === "PUT" || request.method === "POST") {
@@ -2833,6 +2855,8 @@ export function createHandler(engine, options) {
2833
2855
  // A picture of it, and a line about it, where there is one.
2834
2856
  art: artOf(one),
2835
2857
  about: one.about ?? "",
2858
+ // Over, with the outro playing: the page says so rather than "LIVE".
2859
+ ...(one.ended ? { ended: one.ended } : {}),
2836
2860
  })),
2837
2861
  // Anything re-streamed into this server is a live stream too, and was
2838
2862
  // sitting in the middle of the playlist among the files -- which is
@@ -4705,8 +4729,14 @@ export async function serve(argv, version = "0.1.0") {
4705
4729
  });
4706
4730
  // The pictures of channels, taken once each and kept while the channel is on.
4707
4731
  const art = new ArtCache();
4732
+ // What a channel plays once its show is over: drawn here, once, by the
4733
+ // same ffmpeg that carries the channels. No ffmpeg, no outro, and a show
4734
+ // that ends closes its channel as before.
4735
+ const outro = new Outro({ ffmpeg: tools.ffmpeg, dir: join(stateDir(), "outro"), onEvent: (message) => console.log(message) });
4708
4736
  const channels = new Channels({
4709
4737
  ffmpeg: tools.ffmpeg,
4738
+ ...(tools.carries ? { outro: (kind) => outro.clip(kind) } : {}),
4739
+ onOutro: (info) => console.log(` "${info.id}" has ended; the outro plays for an hour.`),
4710
4740
  onStart: (info) => console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
4711
4741
  onEnd: (info) => {
4712
4742
  console.log(` "${info.id}" stopped.`);
@@ -5224,8 +5254,14 @@ export async function serve(argv, version = "0.1.0") {
5224
5254
  ...(rooms ? { rooms } : {}),
5225
5255
  ...(trollbox ? { trollbox } : {}),
5226
5256
  ...(speech ? { speech } : {}),
5227
- // Other people's OpenProfiles, read for the voice a line is spoken in.
5257
+ // Other people's OpenProfiles, read for the voice a line is spoken in,
5258
+ // and the voices to speak in: ElevenLabs when the Telnyx account holds
5259
+ // the key for it (an integration secret named "elevenlabs"), Kokoro
5260
+ // otherwise. Nothing to configure on the deployment for either.
5228
5261
  ...(accounts ? { profiles: new Profiles() } : {}),
5262
+ ...(accounts && process.env["TELNYX_API_KEY"]
5263
+ ? { voices: new Voices({ telnyxApiKey: process.env["TELNYX_API_KEY"], onEvent: (message) => console.log(message) }) }
5264
+ : {}),
5229
5265
  ...(tickets ? { tickets } : {}),
5230
5266
  ...(authServer ? { authServer } : {}),
5231
5267
  ...(parties ? { parties } : {}),