nixamp 0.23.5 → 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 +16 -8
- package/dist/main.js +28 -1
- package/dist/mcp.js +50 -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/server.d.ts +3 -1
- package/dist/server.js +32 -5
- package/dist/voices.d.ts +70 -7
- package/dist/voices.js +154 -25
- package/package.json +1 -1
- package/src/main.ts +28 -1
- package/src/mcp.ts +47 -0
- package/src/partyline.ts +3 -1
- package/src/profile.ts +174 -0
- package/src/server.ts +35 -5
- package/src/voices.ts +184 -22
- package/web/dist/assets/{hls-3VKVEQE3-Dq51v8Gv.js → hls-3VKVEQE3-CqYm-w53.js} +1 -1
- package/web/dist/assets/{index-zCUbE3U5.js → index-Qo7uZEAJ.js} +1 -1
- package/web/dist/assets/{mpegts-WAX5pHld.js → mpegts-BXa-l6Og.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dz2Fz7af.js → mpegts-LO6RVLD6-CkxdQRL_.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
package/README.md
CHANGED
|
@@ -314,14 +314,22 @@ which case the words wait in the box for Send. A wrong line is taken down
|
|
|
314
314
|
with its ✕.
|
|
315
315
|
|
|
316
316
|
**On the phone, too.** Every live room has a six-digit code on the party
|
|
317
|
-
line (see below), and when
|
|
318
|
-
line is read aloud to them: "chovy says: …", in a voice that is
|
|
319
|
-
far as a machine can manage
|
|
320
|
-
|
|
321
|
-
`
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
317
|
+
line (see below), and only when somebody is on the phone in a room, each
|
|
318
|
+
trollbox line is read aloud to them: "chovy says: …", in a voice that is
|
|
319
|
+
theirs as far as a machine can manage, and different from everybody
|
|
320
|
+
else's in the room. The Account panel (or `nixamp profile`, or the
|
|
321
|
+
`profile_set` MCP tool) sets it: a woman's voice, a man's, any, or a voice
|
|
322
|
+
id; or an OpenProfile URL, whose `Voice`, `Gender` or `Pronouns` decide.
|
|
323
|
+
Given a sex, the account picks one voice from that sex's pool and keeps
|
|
324
|
+
it; given nothing, one from the whole pool. `nixamp voices` lists them.
|
|
325
|
+
|
|
326
|
+
Two pools. Telnyx's Kokoro voices are an open-weights model with no bill
|
|
327
|
+
beyond the call: eleven women, eight men. ElevenLabs reads better and bills
|
|
328
|
+
per character: when the Telnyx account holds an integration secret named
|
|
329
|
+
`elevenlabs` with the ElevenLabs key, nixamp.com finds it on its own and
|
|
330
|
+
uses ElevenLabs' premade voices by their labelled gender; `NIXAMP_TTS=kokoro`
|
|
331
|
+
keeps the free ones regardless. `NIXAMP_VOICES_FEMALE` / `NIXAMP_VOICES_MALE`
|
|
332
|
+
(comma lists of Telnyx voice ids) replace either pool outright.
|
|
325
333
|
The ear is [Whisper](https://github.com/openai/whisper) run through
|
|
326
334
|
[Transformers.js](https://github.com/huggingface/transformers.js), an
|
|
327
335
|
Apache-2.0 library carrying MIT-licensed models, on nixamp.com's own CPU.
|
package/dist/main.js
CHANGED
|
@@ -62,6 +62,8 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
62
62
|
nixamp mcp speak Model Context Protocol on stdin, for an agent
|
|
63
63
|
nixamp transcribe FILE [--say SERVER] the words in a recording, and into a trollbox
|
|
64
64
|
nixamp transcript --channel ID [--follow] what a channel is saying, as it says it
|
|
65
|
+
nixamp profile [--handle H] [--voice V] [--profile URL] who the rooms know you as
|
|
66
|
+
nixamp voices the voices a line is read in on the phone
|
|
65
67
|
nixamp opendir list|add|remove folders found on the web, published for everyone
|
|
66
68
|
nixamp update [version] re-run the installer, keeping your choices
|
|
67
69
|
nixamp uninstall [--yes] remove everything the installer created
|
|
@@ -242,7 +244,8 @@ It offers the watch party tools: list them, read one, put one on the air,
|
|
|
242
244
|
say where playback is, end it. And the room tools: transcribe a recording
|
|
243
245
|
(transcribe_audio, which can post the words straight into a trollbox), say a
|
|
244
246
|
line in a room (trollbox_say), read a room (trollbox_read), read what a
|
|
245
|
-
channel is saying (transcript_read)
|
|
247
|
+
channel is saying (transcript_read), and who you are in the rooms and how
|
|
248
|
+
you sound on the phone (profile_get, profile_set, voices_list). It acts as
|
|
246
249
|
whoever this machine is signed in as, so \`nixamp login\` (or NIXAMP_TOKEN)
|
|
247
250
|
comes first.
|
|
248
251
|
|
|
@@ -263,6 +266,20 @@ sign-in (\`nixamp login\`) and nothing else. Up to a minute at a time.
|
|
|
263
266
|
|
|
264
267
|
The same ear is behind the microphone button in every nixamp.com trollbox,
|
|
265
268
|
and behind the transcribe_audio tool of \`nixamp mcp\`.
|
|
269
|
+
`,
|
|
270
|
+
profile: `nixamp profile — who the rooms know you as.
|
|
271
|
+
|
|
272
|
+
nixamp profile your handle, voice and OpenProfile
|
|
273
|
+
nixamp profile --handle chovy the name on every line you say
|
|
274
|
+
nixamp profile --voice female the voice your lines are read in on the phone:
|
|
275
|
+
female, male, any, or a voice id (see \`nixamp voices\`)
|
|
276
|
+
nixamp profile --profile URL your OpenProfile.md; its Voice, Gender or Pronouns
|
|
277
|
+
decide the voice when you set none here
|
|
278
|
+
nixamp voices the voices this nixamp.com reads lines in
|
|
279
|
+
|
|
280
|
+
When somebody is on the phone in a live room, every trollbox line is read to
|
|
281
|
+
them in the author's voice: the one set here, else the OpenProfile's, else
|
|
282
|
+
one picked for the account and kept. Two people in a room are two voices.
|
|
266
283
|
`,
|
|
267
284
|
transcript: `nixamp transcript — what a channel is saying, written down.
|
|
268
285
|
|
|
@@ -473,6 +490,16 @@ export async function main() {
|
|
|
473
490
|
process.exitCode = await party(rest);
|
|
474
491
|
return;
|
|
475
492
|
}
|
|
493
|
+
if (first === "profile" || first === "persona") {
|
|
494
|
+
const { profile } = await import("./profile.js");
|
|
495
|
+
process.exitCode = await profile(rest);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (first === "voices") {
|
|
499
|
+
const { voices } = await import("./profile.js");
|
|
500
|
+
process.exitCode = await voices(rest);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
476
503
|
if (first === "transcript" || first === "captions") {
|
|
477
504
|
const { transcript } = await import("./transcript.js");
|
|
478
505
|
process.exitCode = await transcript(rest);
|
package/dist/mcp.js
CHANGED
|
@@ -22,6 +22,7 @@ import { clock } from "./party.js";
|
|
|
22
22
|
import { readSession } from "./session.js";
|
|
23
23
|
import { askToHear, wavOf } from "./transcribe.js";
|
|
24
24
|
import { readTranscript } from "./transcript.js";
|
|
25
|
+
import { personaLines, readPersona, readVoices, writePersona } from "./profile.js";
|
|
25
26
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
26
27
|
const STRING = { type: "string" };
|
|
27
28
|
export const TOOLS = [
|
|
@@ -119,6 +120,28 @@ export const TOOLS = [
|
|
|
119
120
|
required: ["url"],
|
|
120
121
|
},
|
|
121
122
|
},
|
|
123
|
+
{
|
|
124
|
+
name: "profile_get",
|
|
125
|
+
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.",
|
|
126
|
+
inputSchema: { type: "object", properties: {} },
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "profile_set",
|
|
130
|
+
description: "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.",
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: {
|
|
134
|
+
handle: { ...STRING, description: "Letters, digits and hyphens, 2 to 30 characters." },
|
|
135
|
+
voice: { ...STRING, description: "female, male, any, or a voice id such as ElevenLabs.pNInz6obpgDQGcFmaJgB." },
|
|
136
|
+
profile: { ...STRING, description: "The URL of an OpenProfile.md, or an empty string to clear it." },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "voices_list",
|
|
142
|
+
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.",
|
|
143
|
+
inputSchema: { type: "object", properties: {} },
|
|
144
|
+
},
|
|
122
145
|
{
|
|
123
146
|
name: "trollbox_read",
|
|
124
147
|
description: "The recent lines in a live room's trollbox, oldest first: who said what, and when.",
|
|
@@ -289,6 +312,33 @@ export async function callTool(name, args, options = {}) {
|
|
|
289
312
|
}
|
|
290
313
|
return text(got.answer.recent.map((line) => `${new Date(line.at).toISOString()} ${line.text}`).join("\n"));
|
|
291
314
|
}
|
|
315
|
+
if (name === "profile_get") {
|
|
316
|
+
const answer = await readPersona(session, send);
|
|
317
|
+
if (!answer.ok)
|
|
318
|
+
return failed(answer.error);
|
|
319
|
+
return text(personaLines(answer.body).join("\n"));
|
|
320
|
+
}
|
|
321
|
+
if (name === "profile_set") {
|
|
322
|
+
const wanted = {};
|
|
323
|
+
if (typeof args["handle"] === "string")
|
|
324
|
+
wanted.handle = args["handle"];
|
|
325
|
+
if (typeof args["voice"] === "string")
|
|
326
|
+
wanted.voice = args["voice"];
|
|
327
|
+
if (typeof args["profile"] === "string")
|
|
328
|
+
wanted.profile = args["profile"];
|
|
329
|
+
if (Object.keys(wanted).length === 0)
|
|
330
|
+
return failed("Set what? Pass handle, voice and/or profile.");
|
|
331
|
+
const answer = await writePersona(session, wanted, send);
|
|
332
|
+
if (!answer.ok)
|
|
333
|
+
return failed(answer.error);
|
|
334
|
+
return text(personaLines(answer.body).join("\n"));
|
|
335
|
+
}
|
|
336
|
+
if (name === "voices_list") {
|
|
337
|
+
const answer = await readVoices(session, send);
|
|
338
|
+
if (!answer.ok)
|
|
339
|
+
return failed(answer.error);
|
|
340
|
+
return text([`Voices: ${answer.body.provider}.`, "Women:", ...answer.body.female.map((one) => ` ${one}`), "Men:", ...answer.body.male.map((one) => ` ${one}`)].join("\n"));
|
|
341
|
+
}
|
|
292
342
|
if (name === "trollbox_read") {
|
|
293
343
|
if (!server)
|
|
294
344
|
return failed("Which room? Pass the server's address.");
|
package/dist/partyline.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/profile.js
ADDED
|
@@ -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/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,7 @@ 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,
|
|
70
|
+
import { Profiles, Voices, spokenLine, spokenVoiceFor } from "./voices.js";
|
|
71
71
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
72
72
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
73
73
|
import { isRemote, isTransportStream, playsInBrowser, sourceLabel } from "./sources.js";
|
|
@@ -2212,10 +2212,26 @@ export function createHandler(engine, options) {
|
|
|
2212
2212
|
void (async () => {
|
|
2213
2213
|
const persona = (await options.handles?.persona(authorId)) ?? { handle, voice: "", profile: "" };
|
|
2214
2214
|
const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
|
|
2215
|
-
const
|
|
2216
|
-
|
|
2215
|
+
const pools = options.voices ? await options.voices.pools() : { provider: "kokoro", female: [], male: [] };
|
|
2216
|
+
const spoken = spokenVoiceFor({ userId: authorId, voice: persona.voice, profile }, pools);
|
|
2217
|
+
await partyLine.say(code, spokenLine(handle, body), spoken.voice, spoken.settings);
|
|
2217
2218
|
})().catch(() => undefined);
|
|
2218
2219
|
};
|
|
2220
|
+
/*
|
|
2221
|
+
* The voices a line can be read in, for whoever wants to pick one by
|
|
2222
|
+
* id: which provider, and the women's and the men's pools. Signed in,
|
|
2223
|
+
* like the profile it feeds.
|
|
2224
|
+
*/
|
|
2225
|
+
if (path === "/api/v1/voices" && options.accounts) {
|
|
2226
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
2227
|
+
if (who === null) {
|
|
2228
|
+
json(response, 401, { error: "not signed in" });
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
const pools = options.voices ? await options.voices.pools() : { provider: "kokoro", female: [], male: [] };
|
|
2232
|
+
json(response, 200, { provider: pools.provider, female: pools.female, male: pools.male });
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2219
2235
|
/*
|
|
2220
2236
|
* The trollbox: the chat for one live room, keyed by the server and
|
|
2221
2237
|
* the channel so every viewer of a stream is in the same box whichever
|
|
@@ -2366,7 +2382,12 @@ export function createHandler(engine, options) {
|
|
|
2366
2382
|
// be sent alone; what is not sent is kept.
|
|
2367
2383
|
if (request.method === "GET") {
|
|
2368
2384
|
const persona = await handles.persona(who.id);
|
|
2369
|
-
|
|
2385
|
+
// And the voice a line of theirs would be read in right now, so the
|
|
2386
|
+
// panel and the CLI can say it rather than describe the rule.
|
|
2387
|
+
const pools = options.voices ? await options.voices.pools() : null;
|
|
2388
|
+
const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
|
|
2389
|
+
const spoken = pools ? spokenVoiceFor({ userId: who.id, voice: persona.voice, profile }, pools).voice : "";
|
|
2390
|
+
json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "", spoken });
|
|
2370
2391
|
return;
|
|
2371
2392
|
}
|
|
2372
2393
|
if (request.method === "PUT" || request.method === "POST") {
|
|
@@ -5224,8 +5245,14 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5224
5245
|
...(rooms ? { rooms } : {}),
|
|
5225
5246
|
...(trollbox ? { trollbox } : {}),
|
|
5226
5247
|
...(speech ? { speech } : {}),
|
|
5227
|
-
// Other people's OpenProfiles, read for the voice a line is spoken in
|
|
5248
|
+
// Other people's OpenProfiles, read for the voice a line is spoken in,
|
|
5249
|
+
// and the voices to speak in: ElevenLabs when the Telnyx account holds
|
|
5250
|
+
// the key for it (an integration secret named "elevenlabs"), Kokoro
|
|
5251
|
+
// otherwise. Nothing to configure on the deployment for either.
|
|
5228
5252
|
...(accounts ? { profiles: new Profiles() } : {}),
|
|
5253
|
+
...(accounts && process.env["TELNYX_API_KEY"]
|
|
5254
|
+
? { voices: new Voices({ telnyxApiKey: process.env["TELNYX_API_KEY"], onEvent: (message) => console.log(message) }) }
|
|
5255
|
+
: {}),
|
|
5229
5256
|
...(tickets ? { tickets } : {}),
|
|
5230
5257
|
...(authServer ? { authServer } : {}),
|
|
5231
5258
|
...(parties ? { parties } : {}),
|
package/dist/voices.d.ts
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
export type VoiceKind = "female" | "male" | "";
|
|
2
|
-
/** Kokoro
|
|
2
|
+
/** Kokoro through Telnyx: American English, the women then the men. Free. */
|
|
3
|
+
export declare const KOKORO_FEMALE: string[];
|
|
4
|
+
export declare const KOKORO_MALE: string[];
|
|
5
|
+
/**
|
|
6
|
+
* ElevenLabs' premade voices, as `ElevenLabs.<voice id>`, by the gender
|
|
7
|
+
* ElevenLabs labels them with (read off the account on 2026-09-13). The
|
|
8
|
+
* names are for whoever reads this file; Telnyx only ever sees the id.
|
|
9
|
+
*/
|
|
10
|
+
export declare const ELEVENLABS_FEMALE: string[];
|
|
11
|
+
export declare const ELEVENLABS_MALE: string[];
|
|
12
|
+
/** Kept for the tests and anybody who wants one voice per sex: the first of each pool. */
|
|
3
13
|
export declare const DEFAULT_VOICES: Record<Exclude<VoiceKind, "">, string>;
|
|
14
|
+
/** The voices to choose among, and what Telnyx needs to speak them. */
|
|
15
|
+
export interface VoicePools {
|
|
16
|
+
provider: "kokoro" | "elevenlabs" | "custom";
|
|
17
|
+
female: string[];
|
|
18
|
+
male: string[];
|
|
19
|
+
/** Sent as `voice_settings` on an ElevenLabs voice: the integration secret holding the key. */
|
|
20
|
+
settings?: {
|
|
21
|
+
api_key_ref: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** What a speak command is told: the voice, and the settings that voice needs, if any. */
|
|
25
|
+
export interface SpokenVoice {
|
|
26
|
+
voice: string;
|
|
27
|
+
settings?: {
|
|
28
|
+
api_key_ref: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
4
31
|
/** The word a person used for themselves, as a voice: female, male, or nothing to go on. */
|
|
5
32
|
export declare function voiceKindOf(value: unknown): VoiceKind;
|
|
6
33
|
export interface ProfileVoice {
|
|
@@ -16,18 +43,54 @@ export interface ProfileVoice {
|
|
|
16
43
|
*/
|
|
17
44
|
export declare function readProfileVoice(markdown: string): ProfileVoice;
|
|
18
45
|
/**
|
|
19
|
-
* The
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* picks
|
|
46
|
+
* The voice for somebody, from what is known about them. A voice named
|
|
47
|
+
* outright (`ElevenLabs.…`, `Telnyx.KokoroTTS.…`, `Polly.…`) is used as
|
|
48
|
+
* written; a sex picks from that sex's pool by the account id; nothing at
|
|
49
|
+
* all picks from both pools the same way, so it stays the same tomorrow.
|
|
50
|
+
* Settings ride along when the voice is an ElevenLabs one and the pools
|
|
51
|
+
* know the secret.
|
|
23
52
|
*/
|
|
24
53
|
export declare function telnyxVoiceFor(who: {
|
|
25
54
|
userId: string;
|
|
26
55
|
voice?: string;
|
|
27
56
|
profile?: ProfileVoice | null;
|
|
28
|
-
},
|
|
29
|
-
|
|
57
|
+
}, pools?: VoicePools | Record<Exclude<VoiceKind, "">, string>): string;
|
|
58
|
+
export declare function spokenVoiceFor(who: {
|
|
59
|
+
userId: string;
|
|
60
|
+
voice?: string;
|
|
61
|
+
profile?: ProfileVoice | null;
|
|
62
|
+
}, pools: VoicePools): SpokenVoice;
|
|
63
|
+
/** The voices to use per sex, from the environment when it says so. Kept for one-voice callers. */
|
|
30
64
|
export declare function voicesFromEnv(env?: NodeJS.ProcessEnv): Record<Exclude<VoiceKind, "">, string>;
|
|
65
|
+
/**
|
|
66
|
+
* The pools this deployment speaks with. `NIXAMP_VOICES_FEMALE` and
|
|
67
|
+
* `NIXAMP_VOICES_MALE` (comma lists of Telnyx voice ids) win outright;
|
|
68
|
+
* `NIXAMP_VOICE_FEMALE` / `NIXAMP_VOICE_MALE` name one voice per sex; else
|
|
69
|
+
* ElevenLabs when the account holds the secret for it and `NIXAMP_TTS` is
|
|
70
|
+
* not `kokoro`; else Kokoro, which is free.
|
|
71
|
+
*/
|
|
72
|
+
export declare function poolsFrom(env: NodeJS.ProcessEnv, elevenLabsSecret: string | null): VoicePools;
|
|
73
|
+
/**
|
|
74
|
+
* Whether Telnyx holds an ElevenLabs key for this account: an integration
|
|
75
|
+
* secret whose identifier is `elevenlabs` (or what NIXAMP_ELEVENLABS_SECRET
|
|
76
|
+
* names). Asked once, at the first line, and remembered; no key means the
|
|
77
|
+
* free voices, and no Railway variable has to be set for either.
|
|
78
|
+
*/
|
|
79
|
+
export declare class Voices {
|
|
80
|
+
private readonly options;
|
|
81
|
+
private secret;
|
|
82
|
+
constructor(options: {
|
|
83
|
+
telnyxApiKey: string;
|
|
84
|
+
env?: NodeJS.ProcessEnv;
|
|
85
|
+
fetcher?: typeof fetch;
|
|
86
|
+
telnyxApi?: string;
|
|
87
|
+
onEvent?: (message: string) => void;
|
|
88
|
+
});
|
|
89
|
+
private get env();
|
|
90
|
+
private elevenLabsSecret;
|
|
91
|
+
/** The pools, resolved once. A failed look at Telnyx is asked again next time. */
|
|
92
|
+
pools(): Promise<VoicePools>;
|
|
93
|
+
}
|
|
31
94
|
/** What a line sounds like read aloud: who said it, then what. */
|
|
32
95
|
export declare function spokenLine(handle: string, body: string): string;
|
|
33
96
|
/**
|