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 CHANGED
@@ -309,8 +309,27 @@ A line can be said rather than typed. The microphone button beside the box
309
309
  is tap, talk, tap: the page records, brings the sound to 16 kHz mono itself,
310
310
  and sends nixamp.com a small WAV with the room's name on it; the ear posts
311
311
  the words to the room in the same request, and the line appears. Said is
312
- sent. A line you would rather see first is typed; a wrong one is taken down
312
+ sent, unless the **Edit before sending** switch under the box is on, in
313
+ which case the words wait in the box for Send. A wrong line is taken down
313
314
  with its ✕.
315
+
316
+ **On the phone, too.** Every live room has a six-digit code on the party
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.
314
333
  The ear is [Whisper](https://github.com/openai/whisper) run through
315
334
  [Transformers.js](https://github.com/huggingface/transformers.js), an
316
335
  Apache-2.0 library carrying MIT-licensed models, on nixamp.com's own CPU.
package/dist/handles.d.ts CHANGED
@@ -11,6 +11,24 @@
11
11
  * field and never travel in the same response.
12
12
  */
13
13
  import type { Queryable } from "./follows.ts";
14
+ /** What the room knows about somebody: the name, how they sound, where the rest is written. */
15
+ export interface Persona {
16
+ handle: string;
17
+ /** female, male, "" for whichever, or a provider voice id they named. */
18
+ voice: string;
19
+ /** The URL of their OpenProfile.md, or "". */
20
+ profile: string;
21
+ }
22
+ /** A voice as it may be kept: a sex, nothing, or a provider's voice id. */
23
+ export declare function cleanVoice(value: unknown): {
24
+ voice: string;
25
+ error: string;
26
+ };
27
+ /** An OpenProfile address as it may be kept: an http(s) URL, or nothing. */
28
+ export declare function cleanProfile(value: unknown): {
29
+ profile: string;
30
+ error: string;
31
+ };
14
32
  /**
15
33
  * What a handle may be.
16
34
  *
@@ -35,6 +53,20 @@ export declare class Handles {
35
53
  constructor(db: Queryable);
36
54
  private ensure;
37
55
  of(userId: string): Promise<string>;
56
+ /** Everything the room knows about somebody. Empty strings for whatever they never said. */
57
+ persona(userId: string): Promise<Persona>;
58
+ /**
59
+ * How somebody sounds, and where their profile is. Either may be given
60
+ * alone; what is not given is kept. A row exists only once a handle does,
61
+ * so somebody who never picked one is given the fallback handle first.
62
+ */
63
+ describe(userId: string, fallbackHandle: string, wanted: {
64
+ voice?: unknown;
65
+ profile?: unknown;
66
+ }): Promise<{
67
+ persona: Persona | null;
68
+ error: string;
69
+ }>;
38
70
  /**
39
71
  * Handles for a page of rows, in one query.
40
72
  *
package/dist/handles.js CHANGED
@@ -7,7 +7,46 @@ const SCHEMA = `
7
7
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
8
8
  );
9
9
  CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_lower ON ${TABLE} (lower(handle));
10
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS voice TEXT NOT NULL DEFAULT '';
11
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS profile TEXT NOT NULL DEFAULT '';
10
12
  `;
13
+ /** A voice as it may be kept: a sex, nothing, or a provider's voice id. */
14
+ export function cleanVoice(value) {
15
+ if (value === undefined || value === null)
16
+ return { voice: "", error: "" };
17
+ if (typeof value !== "string")
18
+ return { voice: "", error: "a voice is a word" };
19
+ const word = value.trim();
20
+ if (word === "" || word === "any")
21
+ return { voice: "", error: "" };
22
+ const lower = word.toLowerCase();
23
+ if (lower === "female" || lower === "male")
24
+ return { voice: lower, error: "" };
25
+ if (/^[A-Za-z][A-Za-z0-9_.-]{2,80}$/.test(word) && word.includes("."))
26
+ return { voice: word, error: "" };
27
+ return { voice: "", error: "female, male, any, or a voice id like Telnyx.KokoroTTS.am_adam" };
28
+ }
29
+ /** An OpenProfile address as it may be kept: an http(s) URL, or nothing. */
30
+ export function cleanProfile(value) {
31
+ if (value === undefined || value === null)
32
+ return { profile: "", error: "" };
33
+ if (typeof value !== "string")
34
+ return { profile: "", error: "a profile is a URL" };
35
+ const text = value.trim();
36
+ if (text === "")
37
+ return { profile: "", error: "" };
38
+ try {
39
+ const url = new URL(text);
40
+ if (url.protocol !== "https:" && url.protocol !== "http:")
41
+ return { profile: "", error: "a profile is an http(s) URL" };
42
+ if (url.href.length > 500)
43
+ return { profile: "", error: "that URL is too long" };
44
+ return { profile: url.href, error: "" };
45
+ }
46
+ catch {
47
+ return { profile: "", error: "a profile is a URL, like https://you.example/.well-known/openprofile.md" };
48
+ }
49
+ }
11
50
  /**
12
51
  * What a handle may be.
13
52
  *
@@ -69,6 +108,36 @@ export class Handles {
69
108
  const { rows } = await this.db.query(`SELECT handle FROM ${TABLE} WHERE user_id = $1`, [userId]);
70
109
  return rows[0] ? String(rows[0]["handle"] ?? "") : "";
71
110
  }
111
+ /** Everything the room knows about somebody. Empty strings for whatever they never said. */
112
+ async persona(userId) {
113
+ await this.ensure();
114
+ const { rows } = await this.db.query(`SELECT handle, voice, profile FROM ${TABLE} WHERE user_id = $1`, [userId]);
115
+ const row = rows[0];
116
+ return {
117
+ handle: row ? String(row["handle"] ?? "") : "",
118
+ voice: row ? String(row["voice"] ?? "") : "",
119
+ profile: row ? String(row["profile"] ?? "") : "",
120
+ };
121
+ }
122
+ /**
123
+ * How somebody sounds, and where their profile is. Either may be given
124
+ * alone; what is not given is kept. A row exists only once a handle does,
125
+ * so somebody who never picked one is given the fallback handle first.
126
+ */
127
+ async describe(userId, fallbackHandle, wanted) {
128
+ const voice = wanted.voice === undefined ? null : cleanVoice(wanted.voice);
129
+ if (voice?.error)
130
+ return { persona: null, error: voice.error };
131
+ const profile = wanted.profile === undefined ? null : cleanProfile(wanted.profile);
132
+ if (profile?.error)
133
+ return { persona: null, error: profile.error };
134
+ await this.ensure();
135
+ const had = await this.persona(userId);
136
+ const handle = had.handle || cleanHandle(fallbackHandle) || fallbackHandle;
137
+ await this.db.query(`INSERT INTO ${TABLE} (user_id, handle, voice, profile) VALUES ($1, $2, $3, $4)
138
+ ON CONFLICT (user_id) DO UPDATE SET voice = EXCLUDED.voice, profile = EXCLUDED.profile, updated_at = NOW()`, [userId, handle, voice ? voice.voice : had.voice, profile ? profile.profile : had.profile]);
139
+ return { persona: await this.persona(userId), error: "" };
140
+ }
72
141
  /**
73
142
  * Handles for a page of rows, in one query.
74
143
  *
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). It acts as
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.");
@@ -255,6 +255,17 @@ export declare class PartyLine {
255
255
  * the room do not hear every arrival welcomed.
256
256
  */
257
257
  private greet;
258
+ /**
259
+ * A line said into a room, to everybody on the phone in it. What the
260
+ * trollbox becomes on the party line: the handle and the words, in the
261
+ * voice of whoever typed them. False when nobody is on the phone there,
262
+ * which is most of the time and costs nothing.
263
+ */
264
+ say(code: string, text: string, voice: string, settings?: {
265
+ api_key_ref: string;
266
+ }): Promise<boolean>;
267
+ /** Whether anybody is on the phone in a room, before a line is worth reading. */
268
+ hasCallers(code: string): boolean;
258
269
  /**
259
270
  * How many people are on the phone for a stream.
260
271
  *
package/dist/partyline.js CHANGED
@@ -583,6 +583,32 @@ export class PartyLine {
583
583
  call_control_ids: [leg],
584
584
  });
585
585
  }
586
+ /**
587
+ * A line said into a room, to everybody on the phone in it. What the
588
+ * trollbox becomes on the party line: the handle and the words, in the
589
+ * voice of whoever typed them. False when nobody is on the phone there,
590
+ * which is most of the time and costs nothing.
591
+ */
592
+ async say(code, text, voice, settings) {
593
+ const room = this.rooms.get(code);
594
+ if (!room || room.conferenceId === null || room.callers === 0)
595
+ return false;
596
+ const said = text.replace(/\s+/g, " ").trim().slice(0, 500);
597
+ if (said === "")
598
+ return false;
599
+ const answer = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
600
+ payload: said,
601
+ voice: voice || this.voice,
602
+ // An ElevenLabs voice needs the key Telnyx holds for it, by name.
603
+ ...(settings ? { voice_settings: settings } : {}),
604
+ });
605
+ return answer !== null;
606
+ }
607
+ /** Whether anybody is on the phone in a room, before a line is worth reading. */
608
+ hasCallers(code) {
609
+ const room = this.rooms.get(code);
610
+ return room !== undefined && room.conferenceId !== null && room.callers > 0;
611
+ }
586
612
  /**
587
613
  * How many people are on the phone for a stream.
588
614
  *
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `nixamp profile` and `nixamp voices` -- who the rooms know you as, from
3
+ * the terminal, and the voices a line can be read in.
4
+ *
5
+ * The same route the Account panel uses (`/api/v1/me/handle`), signed in
6
+ * as this machine is. The MCP tools of the same names are these functions
7
+ * with a different front.
8
+ */
9
+ import { type Session } from "./session.ts";
10
+ export interface Persona {
11
+ handle: string;
12
+ voice: string;
13
+ profile: string;
14
+ chosen: boolean;
15
+ /** The voice a line of theirs would be read in right now. */
16
+ spoken?: string;
17
+ }
18
+ export interface Pools {
19
+ provider: string;
20
+ female: string[];
21
+ male: string[];
22
+ }
23
+ export type Answer<T> = {
24
+ ok: true;
25
+ body: T;
26
+ } | {
27
+ ok: false;
28
+ status: number;
29
+ error: string;
30
+ };
31
+ export declare function readPersona(session: Pick<Session, "site" | "token">, fetcher?: typeof fetch): Promise<Answer<Persona>>;
32
+ export declare function writePersona(session: Pick<Session, "site" | "token">, wanted: {
33
+ handle?: string;
34
+ voice?: string;
35
+ profile?: string;
36
+ }, fetcher?: typeof fetch): Promise<Answer<Persona>>;
37
+ export declare function readVoices(session: Pick<Session, "site" | "token">, fetcher?: typeof fetch): Promise<Answer<Pools>>;
38
+ /** A persona, as the terminal says it. */
39
+ export declare function personaLines(persona: Persona): string[];
40
+ export interface ProfileDeps {
41
+ fetcher?: typeof fetch;
42
+ session?: Pick<Session, "site" | "token"> | null;
43
+ }
44
+ export declare function profile(argv: string[], deps?: ProfileDeps): Promise<number>;
45
+ export declare function voices(argv: string[], deps?: ProfileDeps): Promise<number>;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `nixamp profile` and `nixamp voices` -- who the rooms know you as, from
3
+ * the terminal, and the voices a line can be read in.
4
+ *
5
+ * The same route the Account panel uses (`/api/v1/me/handle`), signed in
6
+ * as this machine is. The MCP tools of the same names are these functions
7
+ * with a different front.
8
+ */
9
+ import { readSession } from "./session.js";
10
+ const HELP = `nixamp profile — who the rooms know you as.
11
+
12
+ nixamp profile your handle, voice and OpenProfile
13
+ nixamp profile --handle chovy the name on every line you say
14
+ nixamp profile --voice female the voice your lines are read in on the phone:
15
+ female, male, any, or a voice id (see \`nixamp voices\`)
16
+ nixamp profile --profile URL your OpenProfile.md; its Voice, Gender or Pronouns
17
+ decide the voice when you set none here
18
+ nixamp profile --json as JSON
19
+ nixamp voices the voices this nixamp.com reads lines in
20
+
21
+ When somebody is on the phone in a live room, every trollbox line is read
22
+ to them in the author's voice: the one set here, else the OpenProfile's,
23
+ else one picked for the account and kept. Two people in a room are two
24
+ different voices.
25
+ `;
26
+ function headers(session) {
27
+ return { authorization: `Bearer ${session.token}`, "content-type": "application/json" };
28
+ }
29
+ export async function readPersona(session, fetcher = fetch) {
30
+ try {
31
+ const response = await fetcher(`${session.site.replace(/\/+$/, "")}/api/v1/me/handle`, { headers: headers(session) });
32
+ const body = (await response.json().catch(() => ({})));
33
+ if (!response.ok)
34
+ return { ok: false, status: response.status, error: body.error ?? `nixamp answered ${response.status}` };
35
+ return { ok: true, body };
36
+ }
37
+ catch (error) {
38
+ return { ok: false, status: 0, error: `could not reach ${session.site}: ${error.message}` };
39
+ }
40
+ }
41
+ export async function writePersona(session, wanted, fetcher = fetch) {
42
+ try {
43
+ const response = await fetcher(`${session.site.replace(/\/+$/, "")}/api/v1/me/handle`, {
44
+ method: "PUT",
45
+ headers: headers(session),
46
+ body: JSON.stringify(wanted),
47
+ });
48
+ const body = (await response.json().catch(() => ({})));
49
+ if (!response.ok)
50
+ return { ok: false, status: response.status, error: body.error ?? `nixamp answered ${response.status}` };
51
+ return { ok: true, body };
52
+ }
53
+ catch (error) {
54
+ return { ok: false, status: 0, error: `could not reach ${session.site}: ${error.message}` };
55
+ }
56
+ }
57
+ export async function readVoices(session, fetcher = fetch) {
58
+ try {
59
+ const response = await fetcher(`${session.site.replace(/\/+$/, "")}/api/v1/voices`, { headers: headers(session) });
60
+ const body = (await response.json().catch(() => ({})));
61
+ if (!response.ok)
62
+ return { ok: false, status: response.status, error: body.error ?? `nixamp answered ${response.status}` };
63
+ return { ok: true, body };
64
+ }
65
+ catch (error) {
66
+ return { ok: false, status: 0, error: `could not reach ${session.site}: ${error.message}` };
67
+ }
68
+ }
69
+ /** A persona, as the terminal says it. */
70
+ export function personaLines(persona) {
71
+ const voice = persona.voice === "female" ? "a woman's voice"
72
+ : persona.voice === "male" ? "a man's voice"
73
+ : persona.voice ? `the voice ${persona.voice}`
74
+ : persona.profile ? "whatever your OpenProfile says, else one picked for you"
75
+ : "one picked for you";
76
+ return [
77
+ persona.chosen ? `You are ${persona.handle} in every room.` : `Rooms call you ${persona.handle} until you pick a handle (--handle).`,
78
+ `On the phone your lines are read in ${voice}${persona.spoken ? ` (${persona.spoken})` : ""}.`,
79
+ persona.profile ? `OpenProfile: ${persona.profile}` : "No OpenProfile set (--profile URL).",
80
+ ];
81
+ }
82
+ function flag(argv, name) {
83
+ const at = argv.indexOf(name);
84
+ return at === -1 ? undefined : argv[at + 1];
85
+ }
86
+ export async function profile(argv, deps = {}) {
87
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
88
+ console.log(HELP);
89
+ return 0;
90
+ }
91
+ const session = deps.session === undefined ? readSession() : deps.session;
92
+ if (session === null) {
93
+ console.error("nixamp: not signed in. Try `nixamp login`.");
94
+ return 1;
95
+ }
96
+ const fetcher = deps.fetcher ?? fetch;
97
+ const wanted = {};
98
+ for (const [name, key] of [["--handle", "handle"], ["--voice", "voice"], ["--profile", "profile"]]) {
99
+ if (argv.includes(name)) {
100
+ const value = flag(argv, name);
101
+ if (value === undefined || value.startsWith("--")) {
102
+ console.error(`nixamp: ${name} needs a value.`);
103
+ return 64;
104
+ }
105
+ wanted[key] = value;
106
+ }
107
+ }
108
+ const answer = Object.keys(wanted).length > 0 ? await writePersona(session, wanted, fetcher) : await readPersona(session, fetcher);
109
+ if (!answer.ok) {
110
+ console.error(`nixamp: ${answer.error}`);
111
+ return 1;
112
+ }
113
+ if (argv.includes("--json")) {
114
+ console.log(JSON.stringify(answer.body, null, 2));
115
+ return 0;
116
+ }
117
+ for (const line of personaLines(answer.body))
118
+ console.log(line);
119
+ return 0;
120
+ }
121
+ export async function voices(argv, deps = {}) {
122
+ if (argv.includes("--help") || argv.includes("-h")) {
123
+ console.log(HELP);
124
+ return 0;
125
+ }
126
+ const session = deps.session === undefined ? readSession() : deps.session;
127
+ if (session === null) {
128
+ console.error("nixamp: not signed in. Try `nixamp login`.");
129
+ return 1;
130
+ }
131
+ const answer = await readVoices(session, deps.fetcher ?? fetch);
132
+ if (!answer.ok) {
133
+ console.error(`nixamp: ${answer.error}`);
134
+ return 1;
135
+ }
136
+ if (argv.includes("--json")) {
137
+ console.log(JSON.stringify(answer.body, null, 2));
138
+ return 0;
139
+ }
140
+ console.log(`Voices: ${answer.body.provider}. Set one with \`nixamp profile --voice <id>\`, or --voice female/male to be given one of these and keep it.`);
141
+ console.log(" Women:");
142
+ for (const one of answer.body.female)
143
+ console.log(` ${one}`);
144
+ console.log(" Men:");
145
+ for (const one of answer.body.male)
146
+ console.log(` ${one}`);
147
+ return 0;
148
+ }
package/dist/server.d.ts CHANGED
@@ -32,6 +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, Voices } from "./voices.ts";
35
36
  import { type Codecs } from "./audio.ts";
36
37
  import { type Tools, type Track } from "./audio.ts";
37
38
  import { type Command, type RemoteTrack, type Snapshot } from "./protocol.ts";
@@ -698,6 +699,10 @@ export interface HandlerOptions {
698
699
  trollbox?: Trollbox;
699
700
  /** Speech to text: a line said out loud, heard here. Needs the optional model. */
700
701
  speech?: Speech;
702
+ /** Other people's OpenProfiles, for the voice their lines are read in. */
703
+ profiles?: Profiles;
704
+ /** The voices lines are read in: ElevenLabs when Telnyx holds the key, Kokoro otherwise. */
705
+ voices?: Voices;
701
706
  /** Tickets: a paid pass to one event's room. Absent means every show is free. */
702
707
  tickets?: Tickets;
703
708
  /**