nixamp 0.23.4 → 0.23.6
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 +20 -1
- package/dist/handles.d.ts +32 -0
- package/dist/handles.js +69 -0
- package/dist/main.js +28 -1
- package/dist/mcp.js +50 -0
- package/dist/partyline.d.ts +11 -0
- package/dist/partyline.js +26 -0
- package/dist/profile.d.ts +45 -0
- package/dist/profile.js +148 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +81 -6
- package/dist/voices.d.ts +107 -0
- package/dist/voices.js +285 -0
- package/package.json +1 -1
- package/src/handles.ts +72 -0
- package/src/main.ts +28 -1
- package/src/mcp.ts +47 -0
- package/src/partyline.ts +26 -0
- package/src/profile.ts +174 -0
- package/src/server.ts +85 -7
- package/src/voices.ts +315 -0
- package/web/dist/assets/{hls-3VKVEQE3-B34ptWVB.js → hls-3VKVEQE3-CqYm-w53.js} +1 -1
- package/web/dist/assets/index-Qo7uZEAJ.js +1 -0
- package/web/dist/assets/{mpegts-DAbMVJ3m.js → mpegts-BXa-l6Og.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dwbw4mqA.js → mpegts-LO6RVLD6-CkxdQRL_.js} +1 -1
- package/web/dist/index.html +22 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-CjxlA6ia.js +0 -1
package/src/main.ts
CHANGED
|
@@ -86,6 +86,8 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
86
86
|
nixamp mcp speak Model Context Protocol on stdin, for an agent
|
|
87
87
|
nixamp transcribe FILE [--say SERVER] the words in a recording, and into a trollbox
|
|
88
88
|
nixamp transcript --channel ID [--follow] what a channel is saying, as it says it
|
|
89
|
+
nixamp profile [--handle H] [--voice V] [--profile URL] who the rooms know you as
|
|
90
|
+
nixamp voices the voices a line is read in on the phone
|
|
89
91
|
nixamp opendir list|add|remove folders found on the web, published for everyone
|
|
90
92
|
nixamp update [version] re-run the installer, keeping your choices
|
|
91
93
|
nixamp uninstall [--yes] remove everything the installer created
|
|
@@ -268,7 +270,8 @@ It offers the watch party tools: list them, read one, put one on the air,
|
|
|
268
270
|
say where playback is, end it. And the room tools: transcribe a recording
|
|
269
271
|
(transcribe_audio, which can post the words straight into a trollbox), say a
|
|
270
272
|
line in a room (trollbox_say), read a room (trollbox_read), read what a
|
|
271
|
-
channel is saying (transcript_read)
|
|
273
|
+
channel is saying (transcript_read), and who you are in the rooms and how
|
|
274
|
+
you sound on the phone (profile_get, profile_set, voices_list). It acts as
|
|
272
275
|
whoever this machine is signed in as, so \`nixamp login\` (or NIXAMP_TOKEN)
|
|
273
276
|
comes first.
|
|
274
277
|
|
|
@@ -289,6 +292,20 @@ sign-in (\`nixamp login\`) and nothing else. Up to a minute at a time.
|
|
|
289
292
|
|
|
290
293
|
The same ear is behind the microphone button in every nixamp.com trollbox,
|
|
291
294
|
and behind the transcribe_audio tool of \`nixamp mcp\`.
|
|
295
|
+
`,
|
|
296
|
+
profile: `nixamp profile — who the rooms know you as.
|
|
297
|
+
|
|
298
|
+
nixamp profile your handle, voice and OpenProfile
|
|
299
|
+
nixamp profile --handle chovy the name on every line you say
|
|
300
|
+
nixamp profile --voice female the voice your lines are read in on the phone:
|
|
301
|
+
female, male, any, or a voice id (see \`nixamp voices\`)
|
|
302
|
+
nixamp profile --profile URL your OpenProfile.md; its Voice, Gender or Pronouns
|
|
303
|
+
decide the voice when you set none here
|
|
304
|
+
nixamp voices the voices this nixamp.com reads lines in
|
|
305
|
+
|
|
306
|
+
When somebody is on the phone in a live room, every trollbox line is read to
|
|
307
|
+
them in the author's voice: the one set here, else the OpenProfile's, else
|
|
308
|
+
one picked for the account and kept. Two people in a room are two voices.
|
|
292
309
|
`,
|
|
293
310
|
transcript: `nixamp transcript — what a channel is saying, written down.
|
|
294
311
|
|
|
@@ -502,6 +519,16 @@ export async function main(): Promise<void> {
|
|
|
502
519
|
process.exitCode = await party(rest);
|
|
503
520
|
return;
|
|
504
521
|
}
|
|
522
|
+
if (first === "profile" || first === "persona") {
|
|
523
|
+
const { profile } = await import("./profile.ts");
|
|
524
|
+
process.exitCode = await profile(rest);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (first === "voices") {
|
|
528
|
+
const { voices } = await import("./profile.ts");
|
|
529
|
+
process.exitCode = await voices(rest);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
505
532
|
if (first === "transcript" || first === "captions") {
|
|
506
533
|
const { transcript } = await import("./transcript.ts");
|
|
507
534
|
process.exitCode = await transcript(rest);
|
package/src/mcp.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { clock, type PartyRow } from "./party.ts";
|
|
|
22
22
|
import { readSession } from "./session.ts";
|
|
23
23
|
import { askToHear, wavOf } from "./transcribe.ts";
|
|
24
24
|
import { readTranscript } from "./transcript.ts";
|
|
25
|
+
import { personaLines, readPersona, readVoices, writePersona } from "./profile.ts";
|
|
25
26
|
|
|
26
27
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
27
28
|
|
|
@@ -142,6 +143,29 @@ export const TOOLS: ToolDefinition[] = [
|
|
|
142
143
|
required: ["url"],
|
|
143
144
|
},
|
|
144
145
|
},
|
|
146
|
+
{
|
|
147
|
+
name: "profile_get",
|
|
148
|
+
description: "Who the rooms know this account as: its handle, the voice its trollbox lines are read in on the phone, the OpenProfile URL, and the voice that would be used right now.",
|
|
149
|
+
inputSchema: { type: "object", properties: {} },
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "profile_set",
|
|
153
|
+
description:
|
|
154
|
+
"Set this account's handle, the voice its lines are read in on the phone (female, male, any, or a voice id from voices_list), and/or its OpenProfile URL (whose Voice, Gender or Pronouns pick the voice when none is set). Any one may be given alone.",
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: "object",
|
|
157
|
+
properties: {
|
|
158
|
+
handle: { ...STRING, description: "Letters, digits and hyphens, 2 to 30 characters." },
|
|
159
|
+
voice: { ...STRING, description: "female, male, any, or a voice id such as ElevenLabs.pNInz6obpgDQGcFmaJgB." },
|
|
160
|
+
profile: { ...STRING, description: "The URL of an OpenProfile.md, or an empty string to clear it." },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "voices_list",
|
|
166
|
+
description: "The voices trollbox lines can be read in on the phone: the provider in use and the women's and men's voice ids.",
|
|
167
|
+
inputSchema: { type: "object", properties: {} },
|
|
168
|
+
},
|
|
145
169
|
{
|
|
146
170
|
name: "trollbox_read",
|
|
147
171
|
description: "The recent lines in a live room's trollbox, oldest first: who said what, and when.",
|
|
@@ -335,6 +359,29 @@ export async function callTool(name: string, args: Record<string, unknown>, opti
|
|
|
335
359
|
return text(got.answer.recent.map((line) => `${new Date(line.at).toISOString()} ${line.text}`).join("\n"));
|
|
336
360
|
}
|
|
337
361
|
|
|
362
|
+
if (name === "profile_get") {
|
|
363
|
+
const answer = await readPersona(session, send);
|
|
364
|
+
if (!answer.ok) return failed(answer.error);
|
|
365
|
+
return text(personaLines(answer.body).join("\n"));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (name === "profile_set") {
|
|
369
|
+
const wanted: { handle?: string; voice?: string; profile?: string } = {};
|
|
370
|
+
if (typeof args["handle"] === "string") wanted.handle = args["handle"];
|
|
371
|
+
if (typeof args["voice"] === "string") wanted.voice = args["voice"];
|
|
372
|
+
if (typeof args["profile"] === "string") wanted.profile = args["profile"];
|
|
373
|
+
if (Object.keys(wanted).length === 0) return failed("Set what? Pass handle, voice and/or profile.");
|
|
374
|
+
const answer = await writePersona(session, wanted, send);
|
|
375
|
+
if (!answer.ok) return failed(answer.error);
|
|
376
|
+
return text(personaLines(answer.body).join("\n"));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (name === "voices_list") {
|
|
380
|
+
const answer = await readVoices(session, send);
|
|
381
|
+
if (!answer.ok) return failed(answer.error);
|
|
382
|
+
return text([`Voices: ${answer.body.provider}.`, "Women:", ...answer.body.female.map((one) => ` ${one}`), "Men:", ...answer.body.male.map((one) => ` ${one}`)].join("\n"));
|
|
383
|
+
}
|
|
384
|
+
|
|
338
385
|
if (name === "trollbox_read") {
|
|
339
386
|
if (!server) return failed("Which room? Pass the server's address.");
|
|
340
387
|
const url = new URL(`${site}/api/v1/trollbox`);
|
package/src/partyline.ts
CHANGED
|
@@ -702,6 +702,32 @@ export class PartyLine {
|
|
|
702
702
|
});
|
|
703
703
|
}
|
|
704
704
|
|
|
705
|
+
/**
|
|
706
|
+
* A line said into a room, to everybody on the phone in it. What the
|
|
707
|
+
* trollbox becomes on the party line: the handle and the words, in the
|
|
708
|
+
* voice of whoever typed them. False when nobody is on the phone there,
|
|
709
|
+
* which is most of the time and costs nothing.
|
|
710
|
+
*/
|
|
711
|
+
async say(code: string, text: string, voice: string, settings?: { api_key_ref: string }): Promise<boolean> {
|
|
712
|
+
const room = this.rooms.get(code);
|
|
713
|
+
if (!room || room.conferenceId === null || room.callers === 0) return false;
|
|
714
|
+
const said = text.replace(/\s+/g, " ").trim().slice(0, 500);
|
|
715
|
+
if (said === "") return false;
|
|
716
|
+
const answer = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
|
|
717
|
+
payload: said,
|
|
718
|
+
voice: voice || this.voice,
|
|
719
|
+
// An ElevenLabs voice needs the key Telnyx holds for it, by name.
|
|
720
|
+
...(settings ? { voice_settings: settings } : {}),
|
|
721
|
+
});
|
|
722
|
+
return answer !== null;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Whether anybody is on the phone in a room, before a line is worth reading. */
|
|
726
|
+
hasCallers(code: string): boolean {
|
|
727
|
+
const room = this.rooms.get(code);
|
|
728
|
+
return room !== undefined && room.conferenceId !== null && room.callers > 0;
|
|
729
|
+
}
|
|
730
|
+
|
|
705
731
|
/**
|
|
706
732
|
* How many people are on the phone for a stream.
|
|
707
733
|
*
|
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/server.ts
CHANGED
|
@@ -88,6 +88,7 @@ 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 { Profiles, Voices, spokenLine, spokenVoiceFor } from "./voices.ts";
|
|
91
92
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
|
|
92
93
|
import {
|
|
93
94
|
applyRemoteConfig,
|
|
@@ -1733,6 +1734,10 @@ export interface HandlerOptions {
|
|
|
1733
1734
|
trollbox?: Trollbox;
|
|
1734
1735
|
/** Speech to text: a line said out loud, heard here. Needs the optional model. */
|
|
1735
1736
|
speech?: Speech;
|
|
1737
|
+
/** Other people's OpenProfiles, for the voice their lines are read in. */
|
|
1738
|
+
profiles?: Profiles;
|
|
1739
|
+
/** The voices lines are read in: ElevenLabs when Telnyx holds the key, Kokoro otherwise. */
|
|
1740
|
+
voices?: Voices;
|
|
1736
1741
|
/** Tickets: a paid pass to one event's room. Absent means every show is free. */
|
|
1737
1742
|
tickets?: Tickets;
|
|
1738
1743
|
/**
|
|
@@ -2703,6 +2708,50 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2703
2708
|
// Separate from the address on purpose. The address is a credential and
|
|
2704
2709
|
// a way to reach somebody; publishing it in a directory listing or an
|
|
2705
2710
|
// invite would be publishing what they log in with.
|
|
2711
|
+
/**
|
|
2712
|
+
* A trollbox line, read aloud to whoever is on the phone in that room.
|
|
2713
|
+
* The room's code comes from the directory (one per channel, one for
|
|
2714
|
+
* the server's own stream), the voice from the author's account or
|
|
2715
|
+
* OpenProfile. Never awaited by the poster: the phone is a side effect
|
|
2716
|
+
* of the chat, and a slow Telnyx must not slow the box.
|
|
2717
|
+
*/
|
|
2718
|
+
const readOnThePhone = (where: { server: string; channel: string }, authorId: string, handle: string, body: string): void => {
|
|
2719
|
+
const partyLine = options.partyLine;
|
|
2720
|
+
const listing = (options.directory?.list() ?? []).find((one) => {
|
|
2721
|
+
try {
|
|
2722
|
+
return new URL(one.url).origin === where.server;
|
|
2723
|
+
} catch {
|
|
2724
|
+
return false;
|
|
2725
|
+
}
|
|
2726
|
+
});
|
|
2727
|
+
if (!partyLine || !listing) return;
|
|
2728
|
+
const code = where.channel === "live" || where.channel === "main" ? listing.code : listing.channelCodes[where.channel] ?? "";
|
|
2729
|
+
if (!code || !partyLine.hasCallers(code)) return;
|
|
2730
|
+
void (async () => {
|
|
2731
|
+
const persona = (await options.handles?.persona(authorId)) ?? { handle, voice: "", profile: "" };
|
|
2732
|
+
const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
|
|
2733
|
+
const pools = options.voices ? await options.voices.pools() : { provider: "kokoro" as const, female: [], male: [] };
|
|
2734
|
+
const spoken = spokenVoiceFor({ userId: authorId, voice: persona.voice, profile }, pools);
|
|
2735
|
+
await partyLine.say(code, spokenLine(handle, body), spoken.voice, spoken.settings);
|
|
2736
|
+
})().catch(() => undefined);
|
|
2737
|
+
};
|
|
2738
|
+
|
|
2739
|
+
/*
|
|
2740
|
+
* The voices a line can be read in, for whoever wants to pick one by
|
|
2741
|
+
* id: which provider, and the women's and the men's pools. Signed in,
|
|
2742
|
+
* like the profile it feeds.
|
|
2743
|
+
*/
|
|
2744
|
+
if (path === "/api/v1/voices" && options.accounts) {
|
|
2745
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
2746
|
+
if (who === null) {
|
|
2747
|
+
json(response, 401, { error: "not signed in" });
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
const pools = options.voices ? await options.voices.pools() : { provider: "kokoro" as const, female: [], male: [] };
|
|
2751
|
+
json(response, 200, { provider: pools.provider, female: pools.female, male: pools.male });
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2706
2755
|
/*
|
|
2707
2756
|
* The trollbox: the chat for one live room, keyed by the server and
|
|
2708
2757
|
* the channel so every viewer of a stream is in the same box whichever
|
|
@@ -2751,6 +2800,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2751
2800
|
}
|
|
2752
2801
|
const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
|
|
2753
2802
|
const line = await trollbox.post(where, who.id, handle, body.body);
|
|
2803
|
+
readOnThePhone(where, who.id, line.handle, line.body);
|
|
2754
2804
|
json(response, 201, { message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true } });
|
|
2755
2805
|
return;
|
|
2756
2806
|
}
|
|
@@ -2819,6 +2869,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2819
2869
|
if (where && options.trollbox && heard.text !== "") {
|
|
2820
2870
|
const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
|
|
2821
2871
|
const line = await options.trollbox.post(where, who.id, handle, heard.text);
|
|
2872
|
+
readOnThePhone(where, who.id, line.handle, line.body);
|
|
2822
2873
|
json(response, 201, {
|
|
2823
2874
|
text: heard.text, seconds: heard.seconds, model: speech.model,
|
|
2824
2875
|
message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true },
|
|
@@ -2841,24 +2892,43 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2841
2892
|
return;
|
|
2842
2893
|
}
|
|
2843
2894
|
|
|
2895
|
+
// The handle, and since 0.23.4 the voice a line is read in on the
|
|
2896
|
+
// phone and the OpenProfile it may be read from. Any of the three may
|
|
2897
|
+
// be sent alone; what is not sent is kept.
|
|
2844
2898
|
if (request.method === "GET") {
|
|
2845
|
-
|
|
2899
|
+
const persona = await handles.persona(who.id);
|
|
2900
|
+
// And the voice a line of theirs would be read in right now, so the
|
|
2901
|
+
// panel and the CLI can say it rather than describe the rule.
|
|
2902
|
+
const pools = options.voices ? await options.voices.pools() : null;
|
|
2903
|
+
const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
|
|
2904
|
+
const spoken = pools ? spokenVoiceFor({ userId: who.id, voice: persona.voice, profile }, pools).voice : "";
|
|
2905
|
+
json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "", spoken });
|
|
2846
2906
|
return;
|
|
2847
2907
|
}
|
|
2848
2908
|
if (request.method === "PUT" || request.method === "POST") {
|
|
2849
|
-
let body: { handle?: unknown };
|
|
2909
|
+
let body: { handle?: unknown; voice?: unknown; profile?: unknown };
|
|
2850
2910
|
try {
|
|
2851
2911
|
body = JSON.parse(await readBody(request)) as typeof body;
|
|
2852
2912
|
} catch {
|
|
2853
2913
|
json(response, 400, { error: "bad JSON" });
|
|
2854
2914
|
return;
|
|
2855
2915
|
}
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2916
|
+
if (body.handle !== undefined) {
|
|
2917
|
+
const claimed = await handles.claim(who.id, body.handle);
|
|
2918
|
+
if (claimed.error) {
|
|
2919
|
+
json(response, 409, { error: claimed.error });
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2860
2922
|
}
|
|
2861
|
-
|
|
2923
|
+
if (body.voice !== undefined || body.profile !== undefined) {
|
|
2924
|
+
const described = await handles.describe(who.id, fallbackHandle(who.id), { voice: body.voice, profile: body.profile });
|
|
2925
|
+
if (described.error) {
|
|
2926
|
+
json(response, 422, { error: described.error });
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
const persona = await handles.persona(who.id);
|
|
2931
|
+
json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "" });
|
|
2862
2932
|
return;
|
|
2863
2933
|
}
|
|
2864
2934
|
json(response, 405, { error: "GET or PUT" });
|
|
@@ -5824,6 +5894,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
5824
5894
|
...(rooms ? { rooms } : {}),
|
|
5825
5895
|
...(trollbox ? { trollbox } : {}),
|
|
5826
5896
|
...(speech ? { speech } : {}),
|
|
5897
|
+
// Other people's OpenProfiles, read for the voice a line is spoken in,
|
|
5898
|
+
// and the voices to speak in: ElevenLabs when the Telnyx account holds
|
|
5899
|
+
// the key for it (an integration secret named "elevenlabs"), Kokoro
|
|
5900
|
+
// otherwise. Nothing to configure on the deployment for either.
|
|
5901
|
+
...(accounts ? { profiles: new Profiles() } : {}),
|
|
5902
|
+
...(accounts && process.env["TELNYX_API_KEY"]
|
|
5903
|
+
? { voices: new Voices({ telnyxApiKey: process.env["TELNYX_API_KEY"], onEvent: (message) => console.log(message) }) }
|
|
5904
|
+
: {}),
|
|
5827
5905
|
...(tickets ? { tickets } : {}),
|
|
5828
5906
|
...(authServer ? { authServer } : {}),
|
|
5829
5907
|
...(parties ? { parties } : {}),
|