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/dist/voices.js CHANGED
@@ -4,21 +4,60 @@
4
4
  * A trollbox line is text; the party line is a phone call. Reading one into
5
5
  * the other needs a voice, and the voice should be theirs as far as a
6
6
  * machine can manage: a man's line in a man's voice, a woman's in a woman's,
7
- * anybody else's in whichever they asked for. Where that comes from, in
8
- * order: the voice they set on their nixamp account; their OpenProfile
7
+ * and two people in one room in two different voices, so a caller can tell
8
+ * who is talking without being told every time. Where the sex comes from,
9
+ * in order: the voice they set on their nixamp account; their OpenProfile
9
10
  * (`Voice`, then `Gender`, then `Pronouns`; see logicsrc.com/openprofile);
10
- * and failing both, one picked from their id, so the same person is always
11
- * the same voice even when nobody was told which.
11
+ * and failing both, nothing, in which case they get a voice from the whole
12
+ * pool. Which voice within the pool is picked from their account id, so the
13
+ * same person is always the same voice.
12
14
  *
13
- * The voices are Telnyx's Kokoro ones: an open-weights model, no bill beyond
14
- * the call. ElevenLabs or anything else Telnyx speaks is a configuration
15
- * away, per sex, because "better" and "paid" are the same word there.
15
+ * Two pools. Telnyx's Kokoro voices are an open-weights model with no bill
16
+ * beyond the call: eleven women, eight men, American English. ElevenLabs
17
+ * reads better and bills per character; Telnyx speaks it when the account
18
+ * holds an integration secret with the ElevenLabs key, and this file uses
19
+ * it when that secret exists unless told to stay with Kokoro. Both pools
20
+ * can be replaced from the environment.
16
21
  */
17
22
  import { createHash } from "node:crypto";
18
- /** Kokoro, through Telnyx: af_heart is the default woman, am_adam the default man. */
23
+ /** Kokoro through Telnyx: American English, the women then the men. Free. */
24
+ export const KOKORO_FEMALE = ["af_heart", "af_bella", "af_nicole", "af_sarah", "af_sky", "af_nova", "af_jessica", "af_kore", "af_river", "af_alloy", "af_aoede"]
25
+ .map((name) => `Telnyx.KokoroTTS.${name}`);
26
+ export const KOKORO_MALE = ["am_adam", "am_michael", "am_echo", "am_eric", "am_fenrir", "am_liam", "am_onyx", "am_puck"]
27
+ .map((name) => `Telnyx.KokoroTTS.${name}`);
28
+ /**
29
+ * ElevenLabs' premade voices, as `ElevenLabs.<voice id>`, by the gender
30
+ * ElevenLabs labels them with (read off the account on 2026-09-13). The
31
+ * names are for whoever reads this file; Telnyx only ever sees the id.
32
+ */
33
+ export const ELEVENLABS_FEMALE = [
34
+ "ElevenLabs.EXAVITQu4vr4xnSDxMaL", // Sarah
35
+ "ElevenLabs.FGY2WhTYpPnrIDTdsKH5", // Laura
36
+ "ElevenLabs.Xb7hH8MSUJpSbSDYk0k2", // Alice
37
+ "ElevenLabs.XrExE9yKIg1WjnnlVkGX", // Matilda
38
+ "ElevenLabs.cgSgspJ2msm6clMCkdW9", // Jessica
39
+ "ElevenLabs.hpp4J3VqNfWAUOO0d1Us", // Bella
40
+ "ElevenLabs.pFZP5JQG7iQjIQuC4Bku", // Lily
41
+ ];
42
+ export const ELEVENLABS_MALE = [
43
+ "ElevenLabs.CwhRBWXzGAHq8TQ4Fs17", // Roger
44
+ "ElevenLabs.IKne3meq5aSn9XLyUdCD", // Charlie
45
+ "ElevenLabs.JBFqnCBsd6RMkjVDRZzb", // George
46
+ "ElevenLabs.N2lVS1w4EtoT3dr4eOWO", // Callum
47
+ "ElevenLabs.SOYHLrjzK2X1ezoPC6cr", // Harry
48
+ "ElevenLabs.TX3LPaxmHKxFdv7VOQHJ", // Liam
49
+ "ElevenLabs.bIHbv24MWmeRgasZH58o", // Will
50
+ "ElevenLabs.cjVigY5qzO86Huf0OWal", // Eric
51
+ "ElevenLabs.iP95p4xoKVk53GoZ742B", // Chris
52
+ "ElevenLabs.nPczCjzI2devNBz1zQrb", // Brian
53
+ "ElevenLabs.onwK4e9ZLuTAKqWW03F9", // Daniel
54
+ "ElevenLabs.pNInz6obpgDQGcFmaJgB", // Adam
55
+ "ElevenLabs.pqHfZKP75CvOlQylNhV4", // Bill
56
+ ];
57
+ /** Kept for the tests and anybody who wants one voice per sex: the first of each pool. */
19
58
  export const DEFAULT_VOICES = {
20
- female: "Telnyx.KokoroTTS.af_heart",
21
- male: "Telnyx.KokoroTTS.am_adam",
59
+ female: KOKORO_FEMALE[0],
60
+ male: KOKORO_MALE[0],
22
61
  };
23
62
  /** The word a person used for themselves, as a voice: female, male, or nothing to go on. */
24
63
  export function voiceKindOf(value) {
@@ -74,32 +113,122 @@ export function readProfileVoice(markdown) {
74
113
  }
75
114
  return found;
76
115
  }
116
+ /** A stable pick from a list for an id: the same id, the same pick, every time. */
117
+ function pickFor(userId, pool) {
118
+ const digest = createHash("sha1").update(userId).digest();
119
+ const index = (digest[0] << 8 | digest[1]) % pool.length;
120
+ return pool[index];
121
+ }
122
+ /** The pools as one voice per sex, for callers that still think that way. */
123
+ function firstOf(voices) {
124
+ return { provider: "custom", female: [voices.female], male: [voices.male] };
125
+ }
77
126
  /**
78
- * The Telnyx voice for somebody, from what is known about them. A voice
79
- * named outright (`ElevenLabs.…`, `Telnyx.KokoroTTS.…`, `Polly.…`) is used
80
- * as written; a sex picks the configured voice for it; nothing at all
81
- * picks one by the account id, so it stays the same tomorrow.
127
+ * The voice for somebody, from what is known about them. A voice named
128
+ * outright (`ElevenLabs.…`, `Telnyx.KokoroTTS.…`, `Polly.…`) is used as
129
+ * written; a sex picks from that sex's pool by the account id; nothing at
130
+ * all picks from both pools the same way, so it stays the same tomorrow.
131
+ * Settings ride along when the voice is an ElevenLabs one and the pools
132
+ * know the secret.
82
133
  */
83
- export function telnyxVoiceFor(who, voices = DEFAULT_VOICES) {
134
+ export function telnyxVoiceFor(who, pools = { provider: "kokoro", female: KOKORO_FEMALE, male: KOKORO_MALE }) {
135
+ return spokenVoiceFor(who, "female" in pools && typeof pools.female === "string" ? firstOf(pools) : pools).voice;
136
+ }
137
+ export function spokenVoiceFor(who, pools) {
84
138
  const asked = (who.voice ?? "").trim();
85
- if (asked.includes("."))
86
- return asked;
87
139
  const fromProfile = who.profile?.voice.trim() ?? "";
88
- if (fromProfile.includes("."))
89
- return fromProfile;
90
- const kind = voiceKindOf(asked) || voiceKindOf(fromProfile) || voiceKindOf(who.profile?.gender) || voiceKindOf(who.profile?.pronouns);
91
- if (kind)
92
- return voices[kind];
93
- const digit = createHash("sha1").update(who.userId).digest()[0];
94
- return digit % 2 === 0 ? voices.female : voices.male;
140
+ const named = asked.includes(".") ? asked : fromProfile.includes(".") ? fromProfile : "";
141
+ let voice;
142
+ if (named) {
143
+ voice = named;
144
+ }
145
+ else {
146
+ const kind = voiceKindOf(asked) || voiceKindOf(fromProfile) || voiceKindOf(who.profile?.gender) || voiceKindOf(who.profile?.pronouns);
147
+ const pool = kind === "female" ? pools.female : kind === "male" ? pools.male : [...pools.female, ...pools.male];
148
+ voice = pool.length > 0 ? pickFor(who.userId, pool) : DEFAULT_VOICES.female;
149
+ }
150
+ return voice.startsWith("ElevenLabs.") && pools.settings ? { voice, settings: pools.settings } : { voice };
95
151
  }
96
- /** The voices to use per sex, from the environment when it says so. */
152
+ /** The voices to use per sex, from the environment when it says so. Kept for one-voice callers. */
97
153
  export function voicesFromEnv(env = process.env) {
98
154
  return {
99
155
  female: env["NIXAMP_VOICE_FEMALE"] || DEFAULT_VOICES.female,
100
156
  male: env["NIXAMP_VOICE_MALE"] || DEFAULT_VOICES.male,
101
157
  };
102
158
  }
159
+ function listFrom(value) {
160
+ return (value ?? "").split(",").map((one) => one.trim()).filter(Boolean);
161
+ }
162
+ /**
163
+ * The pools this deployment speaks with. `NIXAMP_VOICES_FEMALE` and
164
+ * `NIXAMP_VOICES_MALE` (comma lists of Telnyx voice ids) win outright;
165
+ * `NIXAMP_VOICE_FEMALE` / `NIXAMP_VOICE_MALE` name one voice per sex; else
166
+ * ElevenLabs when the account holds the secret for it and `NIXAMP_TTS` is
167
+ * not `kokoro`; else Kokoro, which is free.
168
+ */
169
+ export function poolsFrom(env, elevenLabsSecret) {
170
+ const female = listFrom(env["NIXAMP_VOICES_FEMALE"]);
171
+ const male = listFrom(env["NIXAMP_VOICES_MALE"]);
172
+ const settings = elevenLabsSecret ? { api_key_ref: elevenLabsSecret } : undefined;
173
+ if (female.length > 0 || male.length > 0) {
174
+ return { provider: "custom", female: female.length > 0 ? female : KOKORO_FEMALE, male: male.length > 0 ? male : KOKORO_MALE, ...(settings ? { settings } : {}) };
175
+ }
176
+ if (env["NIXAMP_VOICE_FEMALE"] || env["NIXAMP_VOICE_MALE"]) {
177
+ const one = voicesFromEnv(env);
178
+ return { provider: "custom", female: [one.female], male: [one.male], ...(settings ? { settings } : {}) };
179
+ }
180
+ if (settings && (env["NIXAMP_TTS"] ?? "").toLowerCase() !== "kokoro") {
181
+ return { provider: "elevenlabs", female: ELEVENLABS_FEMALE, male: ELEVENLABS_MALE, settings };
182
+ }
183
+ return { provider: "kokoro", female: KOKORO_FEMALE, male: KOKORO_MALE };
184
+ }
185
+ /**
186
+ * Whether Telnyx holds an ElevenLabs key for this account: an integration
187
+ * secret whose identifier is `elevenlabs` (or what NIXAMP_ELEVENLABS_SECRET
188
+ * names). Asked once, at the first line, and remembered; no key means the
189
+ * free voices, and no Railway variable has to be set for either.
190
+ */
191
+ export class Voices {
192
+ options;
193
+ secret = null;
194
+ constructor(options) {
195
+ this.options = options;
196
+ }
197
+ get env() {
198
+ return this.options.env ?? process.env;
199
+ }
200
+ async elevenLabsSecret() {
201
+ const wanted = (this.env["NIXAMP_ELEVENLABS_SECRET"] ?? "elevenlabs").trim();
202
+ if (!wanted || !this.options.telnyxApiKey)
203
+ return null;
204
+ try {
205
+ const response = await (this.options.fetcher ?? fetch)(`${this.options.telnyxApi ?? "https://api.telnyx.com/v2"}/integration_secrets?page[size]=250`, {
206
+ headers: { authorization: `Bearer ${this.options.telnyxApiKey}` },
207
+ signal: AbortSignal.timeout(8000),
208
+ });
209
+ if (!response.ok)
210
+ return null;
211
+ const body = (await response.json());
212
+ const found = (body.data ?? []).some((one) => one.identifier === wanted);
213
+ this.options.onEvent?.(found
214
+ ? ` voices: ElevenLabs, through Telnyx secret "${wanted}"`
215
+ : " voices: Kokoro (no ElevenLabs secret on the Telnyx account)");
216
+ return found ? wanted : null;
217
+ }
218
+ catch {
219
+ return null;
220
+ }
221
+ }
222
+ /** The pools, resolved once. A failed look at Telnyx is asked again next time. */
223
+ async pools() {
224
+ this.secret ??= this.elevenLabsSecret().then((secret) => {
225
+ if (secret === null)
226
+ this.secret = null;
227
+ return secret;
228
+ });
229
+ return poolsFrom(this.env, await this.secret);
230
+ }
231
+ }
103
232
  /** What a line sounds like read aloud: who said it, then what. */
104
233
  export function spokenLine(handle, body) {
105
234
  const said = body.replace(/\s+/g, " ").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.23.5",
3
+ "version": "0.23.6",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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). It acts as
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
@@ -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/server.ts CHANGED
@@ -88,7 +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, spokenLine, telnyxVoiceFor, voicesFromEnv } from "./voices.ts";
91
+ import { Profiles, Voices, spokenLine, spokenVoiceFor } from "./voices.ts";
92
92
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
93
93
  import {
94
94
  applyRemoteConfig,
@@ -1736,6 +1736,8 @@ export interface HandlerOptions {
1736
1736
  speech?: Speech;
1737
1737
  /** Other people's OpenProfiles, for the voice their lines are read in. */
1738
1738
  profiles?: Profiles;
1739
+ /** The voices lines are read in: ElevenLabs when Telnyx holds the key, Kokoro otherwise. */
1740
+ voices?: Voices;
1739
1741
  /** Tickets: a paid pass to one event's room. Absent means every show is free. */
1740
1742
  tickets?: Tickets;
1741
1743
  /**
@@ -2728,11 +2730,28 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2728
2730
  void (async () => {
2729
2731
  const persona = (await options.handles?.persona(authorId)) ?? { handle, voice: "", profile: "" };
2730
2732
  const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
2731
- const voice = telnyxVoiceFor({ userId: authorId, voice: persona.voice, profile }, voicesFromEnv());
2732
- await partyLine.say(code, spokenLine(handle, body), voice);
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);
2733
2736
  })().catch(() => undefined);
2734
2737
  };
2735
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
+
2736
2755
  /*
2737
2756
  * The trollbox: the chat for one live room, keyed by the server and
2738
2757
  * the channel so every viewer of a stream is in the same box whichever
@@ -2878,7 +2897,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2878
2897
  // be sent alone; what is not sent is kept.
2879
2898
  if (request.method === "GET") {
2880
2899
  const persona = await handles.persona(who.id);
2881
- json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "" });
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 });
2882
2906
  return;
2883
2907
  }
2884
2908
  if (request.method === "PUT" || request.method === "POST") {
@@ -5870,8 +5894,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5870
5894
  ...(rooms ? { rooms } : {}),
5871
5895
  ...(trollbox ? { trollbox } : {}),
5872
5896
  ...(speech ? { speech } : {}),
5873
- // Other people's OpenProfiles, read for the voice a line is spoken in.
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.
5874
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
+ : {}),
5875
5905
  ...(tickets ? { tickets } : {}),
5876
5906
  ...(authServer ? { authServer } : {}),
5877
5907
  ...(parties ? { parties } : {}),