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/dist/server.js CHANGED
@@ -67,6 +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, Voices, spokenLine, spokenVoiceFor } from "./voices.js";
70
71
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
71
72
  import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
72
73
  import { isRemote, isTransportStream, playsInBrowser, sourceLabel } from "./sources.js";
@@ -2186,6 +2187,51 @@ export function createHandler(engine, options) {
2186
2187
  // Separate from the address on purpose. The address is a credential and
2187
2188
  // a way to reach somebody; publishing it in a directory listing or an
2188
2189
  // invite would be publishing what they log in with.
2190
+ /**
2191
+ * A trollbox line, read aloud to whoever is on the phone in that room.
2192
+ * The room's code comes from the directory (one per channel, one for
2193
+ * the server's own stream), the voice from the author's account or
2194
+ * OpenProfile. Never awaited by the poster: the phone is a side effect
2195
+ * of the chat, and a slow Telnyx must not slow the box.
2196
+ */
2197
+ const readOnThePhone = (where, authorId, handle, body) => {
2198
+ const partyLine = options.partyLine;
2199
+ const listing = (options.directory?.list() ?? []).find((one) => {
2200
+ try {
2201
+ return new URL(one.url).origin === where.server;
2202
+ }
2203
+ catch {
2204
+ return false;
2205
+ }
2206
+ });
2207
+ if (!partyLine || !listing)
2208
+ return;
2209
+ const code = where.channel === "live" || where.channel === "main" ? listing.code : listing.channelCodes[where.channel] ?? "";
2210
+ if (!code || !partyLine.hasCallers(code))
2211
+ return;
2212
+ void (async () => {
2213
+ const persona = (await options.handles?.persona(authorId)) ?? { handle, voice: "", profile: "" };
2214
+ const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
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);
2218
+ })().catch(() => undefined);
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
+ }
2189
2235
  /*
2190
2236
  * The trollbox: the chat for one live room, keyed by the server and
2191
2237
  * the channel so every viewer of a stream is in the same box whichever
@@ -2235,6 +2281,7 @@ export function createHandler(engine, options) {
2235
2281
  }
2236
2282
  const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
2237
2283
  const line = await trollbox.post(where, who.id, handle, body.body);
2284
+ readOnThePhone(where, who.id, line.handle, line.body);
2238
2285
  json(response, 201, { message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true } });
2239
2286
  return;
2240
2287
  }
@@ -2306,6 +2353,7 @@ export function createHandler(engine, options) {
2306
2353
  if (where && options.trollbox && heard.text !== "") {
2307
2354
  const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
2308
2355
  const line = await options.trollbox.post(where, who.id, handle, heard.text);
2356
+ readOnThePhone(where, who.id, line.handle, line.body);
2309
2357
  json(response, 201, {
2310
2358
  text: heard.text, seconds: heard.seconds, model: speech.model,
2311
2359
  message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true },
@@ -2329,8 +2377,17 @@ export function createHandler(engine, options) {
2329
2377
  json(response, 401, { error: "not signed in" });
2330
2378
  return;
2331
2379
  }
2380
+ // The handle, and since 0.23.4 the voice a line is read in on the
2381
+ // phone and the OpenProfile it may be read from. Any of the three may
2382
+ // be sent alone; what is not sent is kept.
2332
2383
  if (request.method === "GET") {
2333
- json(response, 200, { handle: await handles.of(who.id) });
2384
+ const persona = await handles.persona(who.id);
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 });
2334
2391
  return;
2335
2392
  }
2336
2393
  if (request.method === "PUT" || request.method === "POST") {
@@ -2342,12 +2399,22 @@ export function createHandler(engine, options) {
2342
2399
  json(response, 400, { error: "bad JSON" });
2343
2400
  return;
2344
2401
  }
2345
- const claimed = await handles.claim(who.id, body.handle);
2346
- if (claimed.error) {
2347
- json(response, 409, { error: claimed.error });
2348
- return;
2402
+ if (body.handle !== undefined) {
2403
+ const claimed = await handles.claim(who.id, body.handle);
2404
+ if (claimed.error) {
2405
+ json(response, 409, { error: claimed.error });
2406
+ return;
2407
+ }
2408
+ }
2409
+ if (body.voice !== undefined || body.profile !== undefined) {
2410
+ const described = await handles.describe(who.id, fallbackHandle(who.id), { voice: body.voice, profile: body.profile });
2411
+ if (described.error) {
2412
+ json(response, 422, { error: described.error });
2413
+ return;
2414
+ }
2349
2415
  }
2350
- json(response, 200, { handle: claimed.handle });
2416
+ const persona = await handles.persona(who.id);
2417
+ json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "" });
2351
2418
  return;
2352
2419
  }
2353
2420
  json(response, 405, { error: "GET or PUT" });
@@ -5178,6 +5245,14 @@ export async function serve(argv, version = "0.1.0") {
5178
5245
  ...(rooms ? { rooms } : {}),
5179
5246
  ...(trollbox ? { trollbox } : {}),
5180
5247
  ...(speech ? { speech } : {}),
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.
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
+ : {}),
5181
5256
  ...(tickets ? { tickets } : {}),
5182
5257
  ...(authServer ? { authServer } : {}),
5183
5258
  ...(parties ? { parties } : {}),
@@ -0,0 +1,107 @@
1
+ export type VoiceKind = "female" | "male" | "";
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. */
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
+ }
31
+ /** The word a person used for themselves, as a voice: female, male, or nothing to go on. */
32
+ export declare function voiceKindOf(value: unknown): VoiceKind;
33
+ export interface ProfileVoice {
34
+ /** What the profile said outright, when it did: female, male, or a provider voice id. */
35
+ voice: string;
36
+ gender: string;
37
+ pronouns: string;
38
+ }
39
+ /**
40
+ * The parts of an OpenProfile.md that say how somebody sounds. The identity
41
+ * block is the bullets under the first `#`; `Gender` may also sit under
42
+ * `## Match`, where the spec put it first. Everything else is ignored.
43
+ */
44
+ export declare function readProfileVoice(markdown: string): ProfileVoice;
45
+ /**
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.
52
+ */
53
+ export declare function telnyxVoiceFor(who: {
54
+ userId: string;
55
+ voice?: string;
56
+ profile?: ProfileVoice | null;
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. */
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
+ }
94
+ /** What a line sounds like read aloud: who said it, then what. */
95
+ export declare function spokenLine(handle: string, body: string): string;
96
+ /**
97
+ * Somebody's OpenProfile, fetched and kept an hour. A profile is read on
98
+ * every line they say, and a line a second is the trollbox's own limit.
99
+ */
100
+ export declare class Profiles {
101
+ private readonly fetcher;
102
+ private readonly now;
103
+ private readonly ttlMs;
104
+ private readonly kept;
105
+ constructor(fetcher?: typeof fetch, now?: () => number, ttlMs?: number);
106
+ voiceOf(url: string): Promise<ProfileVoice | null>;
107
+ }
package/dist/voices.js ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The voice a person's words are read in, on the phone.
3
+ *
4
+ * A trollbox line is text; the party line is a phone call. Reading one into
5
+ * the other needs a voice, and the voice should be theirs as far as a
6
+ * machine can manage: a man's line in a man's voice, a woman's in a woman's,
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
10
+ * (`Voice`, then `Gender`, then `Pronouns`; see logicsrc.com/openprofile);
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.
14
+ *
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.
21
+ */
22
+ import { createHash } from "node:crypto";
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. */
58
+ export const DEFAULT_VOICES = {
59
+ female: KOKORO_FEMALE[0],
60
+ male: KOKORO_MALE[0],
61
+ };
62
+ /** The word a person used for themselves, as a voice: female, male, or nothing to go on. */
63
+ export function voiceKindOf(value) {
64
+ if (typeof value !== "string")
65
+ return "";
66
+ const word = value.trim().toLowerCase();
67
+ if (word === "")
68
+ return "";
69
+ if (/^(f|female|woman|women|girl|she|her|she\/her|fem|feminine|lady)$/.test(word))
70
+ return "female";
71
+ if (/^(m|male|man|men|boy|he|him|he\/him|masc|masculine|guy)$/.test(word))
72
+ return "male";
73
+ if (/^she\b/.test(word))
74
+ return "female";
75
+ if (/^he\b/.test(word))
76
+ return "male";
77
+ return "";
78
+ }
79
+ /**
80
+ * The parts of an OpenProfile.md that say how somebody sounds. The identity
81
+ * block is the bullets under the first `#`; `Gender` may also sit under
82
+ * `## Match`, where the spec put it first. Everything else is ignored.
83
+ */
84
+ export function readProfileVoice(markdown) {
85
+ const found = { voice: "", gender: "", pronouns: "" };
86
+ let section = "";
87
+ let started = false;
88
+ for (const raw of markdown.split(/\r?\n/)) {
89
+ const line = raw.trim();
90
+ if (line.startsWith("# ") && !started) {
91
+ started = true;
92
+ continue;
93
+ }
94
+ if (line.startsWith("## ")) {
95
+ section = line.slice(3).trim().toLowerCase();
96
+ continue;
97
+ }
98
+ const inIdentity = started && section === "";
99
+ const inMatch = /^(match|dating|matching|partner|looking for)$/.test(section);
100
+ if (!inIdentity && !inMatch)
101
+ continue;
102
+ const item = /^[-*]\s+(?:\*\*)?([A-Za-z ]+?)(?:\*\*)?\s*:\s*(.+)$/.exec(line);
103
+ if (!item)
104
+ continue;
105
+ const key = (item[1] ?? "").trim().toLowerCase();
106
+ const value = (item[2] ?? "").trim();
107
+ if (key === "voice" && !found.voice)
108
+ found.voice = value;
109
+ if (key === "gender" && !found.gender)
110
+ found.gender = value;
111
+ if (key === "pronouns" && !found.pronouns)
112
+ found.pronouns = value;
113
+ }
114
+ return found;
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
+ }
126
+ /**
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.
133
+ */
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) {
138
+ const asked = (who.voice ?? "").trim();
139
+ const fromProfile = who.profile?.voice.trim() ?? "";
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 };
151
+ }
152
+ /** The voices to use per sex, from the environment when it says so. Kept for one-voice callers. */
153
+ export function voicesFromEnv(env = process.env) {
154
+ return {
155
+ female: env["NIXAMP_VOICE_FEMALE"] || DEFAULT_VOICES.female,
156
+ male: env["NIXAMP_VOICE_MALE"] || DEFAULT_VOICES.male,
157
+ };
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
+ }
232
+ /** What a line sounds like read aloud: who said it, then what. */
233
+ export function spokenLine(handle, body) {
234
+ const said = body.replace(/\s+/g, " ").trim();
235
+ return `${handle.replace(/-/g, " ")} says: ${said}`;
236
+ }
237
+ /**
238
+ * Somebody's OpenProfile, fetched and kept an hour. A profile is read on
239
+ * every line they say, and a line a second is the trollbox's own limit.
240
+ */
241
+ export class Profiles {
242
+ fetcher;
243
+ now;
244
+ ttlMs;
245
+ kept = new Map();
246
+ constructor(fetcher = fetch, now = () => Date.now(), ttlMs = 60 * 60 * 1000) {
247
+ this.fetcher = fetcher;
248
+ this.now = now;
249
+ this.ttlMs = ttlMs;
250
+ }
251
+ async voiceOf(url) {
252
+ let where;
253
+ try {
254
+ where = new URL(url);
255
+ if (where.protocol !== "https:" && where.protocol !== "http:")
256
+ return null;
257
+ }
258
+ catch {
259
+ return null;
260
+ }
261
+ const had = this.kept.get(where.href);
262
+ if (had && this.now() - had.at < this.ttlMs)
263
+ return had.voice;
264
+ try {
265
+ const response = await this.fetcher(where.href, {
266
+ headers: { accept: "text/markdown, text/plain;q=0.9, */*;q=0.1" },
267
+ signal: AbortSignal.timeout(5000),
268
+ });
269
+ if (!response.ok)
270
+ return had?.voice ?? null;
271
+ const text = (await response.text()).slice(0, 64 * 1024);
272
+ const voice = readProfileVoice(text);
273
+ this.kept.set(where.href, { at: this.now(), voice });
274
+ if (this.kept.size > 5000) {
275
+ for (const [key, one] of this.kept)
276
+ if (this.now() - one.at > this.ttlMs)
277
+ this.kept.delete(key);
278
+ }
279
+ return voice;
280
+ }
281
+ catch {
282
+ return had?.voice ?? null;
283
+ }
284
+ }
285
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.23.4",
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/handles.ts CHANGED
@@ -22,8 +22,47 @@ const SCHEMA = `
22
22
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
23
23
  );
24
24
  CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_lower ON ${TABLE} (lower(handle));
25
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS voice TEXT NOT NULL DEFAULT '';
26
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS profile TEXT NOT NULL DEFAULT '';
25
27
  `;
26
28
 
29
+ /** What the room knows about somebody: the name, how they sound, where the rest is written. */
30
+ export interface Persona {
31
+ handle: string;
32
+ /** female, male, "" for whichever, or a provider voice id they named. */
33
+ voice: string;
34
+ /** The URL of their OpenProfile.md, or "". */
35
+ profile: string;
36
+ }
37
+
38
+ /** A voice as it may be kept: a sex, nothing, or a provider's voice id. */
39
+ export function cleanVoice(value: unknown): { voice: string; error: string } {
40
+ if (value === undefined || value === null) return { voice: "", error: "" };
41
+ if (typeof value !== "string") return { voice: "", error: "a voice is a word" };
42
+ const word = value.trim();
43
+ if (word === "" || word === "any") return { voice: "", error: "" };
44
+ const lower = word.toLowerCase();
45
+ if (lower === "female" || lower === "male") return { voice: lower, error: "" };
46
+ if (/^[A-Za-z][A-Za-z0-9_.-]{2,80}$/.test(word) && word.includes(".")) return { voice: word, error: "" };
47
+ return { voice: "", error: "female, male, any, or a voice id like Telnyx.KokoroTTS.am_adam" };
48
+ }
49
+
50
+ /** An OpenProfile address as it may be kept: an http(s) URL, or nothing. */
51
+ export function cleanProfile(value: unknown): { profile: string; error: string } {
52
+ if (value === undefined || value === null) return { profile: "", error: "" };
53
+ if (typeof value !== "string") return { profile: "", error: "a profile is a URL" };
54
+ const text = value.trim();
55
+ if (text === "") return { profile: "", error: "" };
56
+ try {
57
+ const url = new URL(text);
58
+ if (url.protocol !== "https:" && url.protocol !== "http:") return { profile: "", error: "a profile is an http(s) URL" };
59
+ if (url.href.length > 500) return { profile: "", error: "that URL is too long" };
60
+ return { profile: url.href, error: "" };
61
+ } catch {
62
+ return { profile: "", error: "a profile is a URL, like https://you.example/.well-known/openprofile.md" };
63
+ }
64
+ }
65
+
27
66
  /**
28
67
  * What a handle may be.
29
68
  *
@@ -87,6 +126,39 @@ export class Handles {
87
126
  return rows[0] ? String(rows[0]["handle"] ?? "") : "";
88
127
  }
89
128
 
129
+ /** Everything the room knows about somebody. Empty strings for whatever they never said. */
130
+ async persona(userId: string): Promise<Persona> {
131
+ await this.ensure();
132
+ const { rows } = await this.db.query(`SELECT handle, voice, profile FROM ${TABLE} WHERE user_id = $1`, [userId]);
133
+ const row = rows[0];
134
+ return {
135
+ handle: row ? String(row["handle"] ?? "") : "",
136
+ voice: row ? String(row["voice"] ?? "") : "",
137
+ profile: row ? String(row["profile"] ?? "") : "",
138
+ };
139
+ }
140
+
141
+ /**
142
+ * How somebody sounds, and where their profile is. Either may be given
143
+ * alone; what is not given is kept. A row exists only once a handle does,
144
+ * so somebody who never picked one is given the fallback handle first.
145
+ */
146
+ async describe(userId: string, fallbackHandle: string, wanted: { voice?: unknown; profile?: unknown }): Promise<{ persona: Persona | null; error: string }> {
147
+ const voice = wanted.voice === undefined ? null : cleanVoice(wanted.voice);
148
+ if (voice?.error) return { persona: null, error: voice.error };
149
+ const profile = wanted.profile === undefined ? null : cleanProfile(wanted.profile);
150
+ if (profile?.error) return { persona: null, error: profile.error };
151
+ await this.ensure();
152
+ const had = await this.persona(userId);
153
+ const handle = had.handle || cleanHandle(fallbackHandle) || fallbackHandle;
154
+ await this.db.query(
155
+ `INSERT INTO ${TABLE} (user_id, handle, voice, profile) VALUES ($1, $2, $3, $4)
156
+ ON CONFLICT (user_id) DO UPDATE SET voice = EXCLUDED.voice, profile = EXCLUDED.profile, updated_at = NOW()`,
157
+ [userId, handle, voice ? voice.voice : had.voice, profile ? profile.profile : had.profile],
158
+ );
159
+ return { persona: await this.persona(userId), error: "" };
160
+ }
161
+
90
162
  /**
91
163
  * Handles for a page of rows, in one query.
92
164
  *