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/src/voices.ts CHANGED
@@ -4,26 +4,82 @@
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
23
 
19
24
  export type VoiceKind = "female" | "male" | "";
20
25
 
21
- /** Kokoro, through Telnyx: af_heart is the default woman, am_adam the default man. */
26
+ /** Kokoro through Telnyx: American English, the women then the men. Free. */
27
+ 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"]
28
+ .map((name) => `Telnyx.KokoroTTS.${name}`);
29
+ export const KOKORO_MALE = ["am_adam", "am_michael", "am_echo", "am_eric", "am_fenrir", "am_liam", "am_onyx", "am_puck"]
30
+ .map((name) => `Telnyx.KokoroTTS.${name}`);
31
+
32
+ /**
33
+ * ElevenLabs' premade voices, as `ElevenLabs.<voice id>`, by the gender
34
+ * ElevenLabs labels them with (read off the account on 2026-09-13). The
35
+ * names are for whoever reads this file; Telnyx only ever sees the id.
36
+ */
37
+ export const ELEVENLABS_FEMALE = [
38
+ "ElevenLabs.EXAVITQu4vr4xnSDxMaL", // Sarah
39
+ "ElevenLabs.FGY2WhTYpPnrIDTdsKH5", // Laura
40
+ "ElevenLabs.Xb7hH8MSUJpSbSDYk0k2", // Alice
41
+ "ElevenLabs.XrExE9yKIg1WjnnlVkGX", // Matilda
42
+ "ElevenLabs.cgSgspJ2msm6clMCkdW9", // Jessica
43
+ "ElevenLabs.hpp4J3VqNfWAUOO0d1Us", // Bella
44
+ "ElevenLabs.pFZP5JQG7iQjIQuC4Bku", // Lily
45
+ ];
46
+ export const ELEVENLABS_MALE = [
47
+ "ElevenLabs.CwhRBWXzGAHq8TQ4Fs17", // Roger
48
+ "ElevenLabs.IKne3meq5aSn9XLyUdCD", // Charlie
49
+ "ElevenLabs.JBFqnCBsd6RMkjVDRZzb", // George
50
+ "ElevenLabs.N2lVS1w4EtoT3dr4eOWO", // Callum
51
+ "ElevenLabs.SOYHLrjzK2X1ezoPC6cr", // Harry
52
+ "ElevenLabs.TX3LPaxmHKxFdv7VOQHJ", // Liam
53
+ "ElevenLabs.bIHbv24MWmeRgasZH58o", // Will
54
+ "ElevenLabs.cjVigY5qzO86Huf0OWal", // Eric
55
+ "ElevenLabs.iP95p4xoKVk53GoZ742B", // Chris
56
+ "ElevenLabs.nPczCjzI2devNBz1zQrb", // Brian
57
+ "ElevenLabs.onwK4e9ZLuTAKqWW03F9", // Daniel
58
+ "ElevenLabs.pNInz6obpgDQGcFmaJgB", // Adam
59
+ "ElevenLabs.pqHfZKP75CvOlQylNhV4", // Bill
60
+ ];
61
+
62
+ /** Kept for the tests and anybody who wants one voice per sex: the first of each pool. */
22
63
  export const DEFAULT_VOICES: Record<Exclude<VoiceKind, "">, string> = {
23
- female: "Telnyx.KokoroTTS.af_heart",
24
- male: "Telnyx.KokoroTTS.am_adam",
64
+ female: KOKORO_FEMALE[0] as string,
65
+ male: KOKORO_MALE[0] as string,
25
66
  };
26
67
 
68
+ /** The voices to choose among, and what Telnyx needs to speak them. */
69
+ export interface VoicePools {
70
+ provider: "kokoro" | "elevenlabs" | "custom";
71
+ female: string[];
72
+ male: string[];
73
+ /** Sent as `voice_settings` on an ElevenLabs voice: the integration secret holding the key. */
74
+ settings?: { api_key_ref: string };
75
+ }
76
+
77
+ /** What a speak command is told: the voice, and the settings that voice needs, if any. */
78
+ export interface SpokenVoice {
79
+ voice: string;
80
+ settings?: { api_key_ref: string };
81
+ }
82
+
27
83
  /** The word a person used for themselves, as a voice: female, male, or nothing to go on. */
28
84
  export function voiceKindOf(value: unknown): VoiceKind {
29
85
  if (typeof value !== "string") return "";
@@ -76,27 +132,52 @@ export function readProfileVoice(markdown: string): ProfileVoice {
76
132
  return found;
77
133
  }
78
134
 
135
+ /** A stable pick from a list for an id: the same id, the same pick, every time. */
136
+ function pickFor(userId: string, pool: string[]): string {
137
+ const digest = createHash("sha1").update(userId).digest();
138
+ const index = ((digest[0] as number) << 8 | (digest[1] as number)) % pool.length;
139
+ return pool[index] as string;
140
+ }
141
+
142
+ /** The pools as one voice per sex, for callers that still think that way. */
143
+ function firstOf(voices: Record<Exclude<VoiceKind, "">, string>): VoicePools {
144
+ return { provider: "custom", female: [voices.female], male: [voices.male] };
145
+ }
146
+
79
147
  /**
80
- * The Telnyx voice for somebody, from what is known about them. A voice
81
- * named outright (`ElevenLabs.…`, `Telnyx.KokoroTTS.…`, `Polly.…`) is used
82
- * as written; a sex picks the configured voice for it; nothing at all
83
- * picks one by the account id, so it stays the same tomorrow.
148
+ * The voice for somebody, from what is known about them. A voice named
149
+ * outright (`ElevenLabs.…`, `Telnyx.KokoroTTS.…`, `Polly.…`) is used as
150
+ * written; a sex picks from that sex's pool by the account id; nothing at
151
+ * all picks from both pools the same way, so it stays the same tomorrow.
152
+ * Settings ride along when the voice is an ElevenLabs one and the pools
153
+ * know the secret.
84
154
  */
85
155
  export function telnyxVoiceFor(
86
156
  who: { userId: string; voice?: string; profile?: ProfileVoice | null },
87
- voices: Record<Exclude<VoiceKind, "">, string> = DEFAULT_VOICES,
157
+ pools: VoicePools | Record<Exclude<VoiceKind, "">, string> = { provider: "kokoro", female: KOKORO_FEMALE, male: KOKORO_MALE },
88
158
  ): string {
159
+ return spokenVoiceFor(who, "female" in pools && typeof pools.female === "string" ? firstOf(pools as Record<Exclude<VoiceKind, "">, string>) : (pools as VoicePools)).voice;
160
+ }
161
+
162
+ export function spokenVoiceFor(
163
+ who: { userId: string; voice?: string; profile?: ProfileVoice | null },
164
+ pools: VoicePools,
165
+ ): SpokenVoice {
89
166
  const asked = (who.voice ?? "").trim();
90
- if (asked.includes(".")) return asked;
91
167
  const fromProfile = who.profile?.voice.trim() ?? "";
92
- if (fromProfile.includes(".")) return fromProfile;
93
- const kind = voiceKindOf(asked) || voiceKindOf(fromProfile) || voiceKindOf(who.profile?.gender) || voiceKindOf(who.profile?.pronouns);
94
- if (kind) return voices[kind];
95
- const digit = createHash("sha1").update(who.userId).digest()[0] as number;
96
- return digit % 2 === 0 ? voices.female : voices.male;
168
+ const named = asked.includes(".") ? asked : fromProfile.includes(".") ? fromProfile : "";
169
+ let voice: string;
170
+ if (named) {
171
+ voice = named;
172
+ } else {
173
+ const kind = voiceKindOf(asked) || voiceKindOf(fromProfile) || voiceKindOf(who.profile?.gender) || voiceKindOf(who.profile?.pronouns);
174
+ const pool = kind === "female" ? pools.female : kind === "male" ? pools.male : [...pools.female, ...pools.male];
175
+ voice = pool.length > 0 ? pickFor(who.userId, pool) : DEFAULT_VOICES.female;
176
+ }
177
+ return voice.startsWith("ElevenLabs.") && pools.settings ? { voice, settings: pools.settings } : { voice };
97
178
  }
98
179
 
99
- /** The voices to use per sex, from the environment when it says so. */
180
+ /** The voices to use per sex, from the environment when it says so. Kept for one-voice callers. */
100
181
  export function voicesFromEnv(env: NodeJS.ProcessEnv = process.env): Record<Exclude<VoiceKind, "">, string> {
101
182
  return {
102
183
  female: env["NIXAMP_VOICE_FEMALE"] || DEFAULT_VOICES.female,
@@ -104,6 +185,87 @@ export function voicesFromEnv(env: NodeJS.ProcessEnv = process.env): Record<Excl
104
185
  };
105
186
  }
106
187
 
188
+ function listFrom(value: string | undefined): string[] {
189
+ return (value ?? "").split(",").map((one) => one.trim()).filter(Boolean);
190
+ }
191
+
192
+ /**
193
+ * The pools this deployment speaks with. `NIXAMP_VOICES_FEMALE` and
194
+ * `NIXAMP_VOICES_MALE` (comma lists of Telnyx voice ids) win outright;
195
+ * `NIXAMP_VOICE_FEMALE` / `NIXAMP_VOICE_MALE` name one voice per sex; else
196
+ * ElevenLabs when the account holds the secret for it and `NIXAMP_TTS` is
197
+ * not `kokoro`; else Kokoro, which is free.
198
+ */
199
+ export function poolsFrom(env: NodeJS.ProcessEnv, elevenLabsSecret: string | null): VoicePools {
200
+ const female = listFrom(env["NIXAMP_VOICES_FEMALE"]);
201
+ const male = listFrom(env["NIXAMP_VOICES_MALE"]);
202
+ const settings = elevenLabsSecret ? { api_key_ref: elevenLabsSecret } : undefined;
203
+ if (female.length > 0 || male.length > 0) {
204
+ return { provider: "custom", female: female.length > 0 ? female : KOKORO_FEMALE, male: male.length > 0 ? male : KOKORO_MALE, ...(settings ? { settings } : {}) };
205
+ }
206
+ if (env["NIXAMP_VOICE_FEMALE"] || env["NIXAMP_VOICE_MALE"]) {
207
+ const one = voicesFromEnv(env);
208
+ return { provider: "custom", female: [one.female], male: [one.male], ...(settings ? { settings } : {}) };
209
+ }
210
+ if (settings && (env["NIXAMP_TTS"] ?? "").toLowerCase() !== "kokoro") {
211
+ return { provider: "elevenlabs", female: ELEVENLABS_FEMALE, male: ELEVENLABS_MALE, settings };
212
+ }
213
+ return { provider: "kokoro", female: KOKORO_FEMALE, male: KOKORO_MALE };
214
+ }
215
+
216
+ /**
217
+ * Whether Telnyx holds an ElevenLabs key for this account: an integration
218
+ * secret whose identifier is `elevenlabs` (or what NIXAMP_ELEVENLABS_SECRET
219
+ * names). Asked once, at the first line, and remembered; no key means the
220
+ * free voices, and no Railway variable has to be set for either.
221
+ */
222
+ export class Voices {
223
+ private secret: Promise<string | null> | null = null;
224
+
225
+ constructor(
226
+ private readonly options: {
227
+ telnyxApiKey: string;
228
+ env?: NodeJS.ProcessEnv;
229
+ fetcher?: typeof fetch;
230
+ telnyxApi?: string;
231
+ onEvent?: (message: string) => void;
232
+ },
233
+ ) {}
234
+
235
+ private get env(): NodeJS.ProcessEnv {
236
+ return this.options.env ?? process.env;
237
+ }
238
+
239
+ private async elevenLabsSecret(): Promise<string | null> {
240
+ const wanted = (this.env["NIXAMP_ELEVENLABS_SECRET"] ?? "elevenlabs").trim();
241
+ if (!wanted || !this.options.telnyxApiKey) return null;
242
+ try {
243
+ const response = await (this.options.fetcher ?? fetch)(`${this.options.telnyxApi ?? "https://api.telnyx.com/v2"}/integration_secrets?page[size]=250`, {
244
+ headers: { authorization: `Bearer ${this.options.telnyxApiKey}` },
245
+ signal: AbortSignal.timeout(8000),
246
+ });
247
+ if (!response.ok) return null;
248
+ const body = (await response.json()) as { data?: { identifier?: string }[] };
249
+ const found = (body.data ?? []).some((one) => one.identifier === wanted);
250
+ this.options.onEvent?.(found
251
+ ? ` voices: ElevenLabs, through Telnyx secret "${wanted}"`
252
+ : " voices: Kokoro (no ElevenLabs secret on the Telnyx account)");
253
+ return found ? wanted : null;
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ /** The pools, resolved once. A failed look at Telnyx is asked again next time. */
260
+ async pools(): Promise<VoicePools> {
261
+ this.secret ??= this.elevenLabsSecret().then((secret) => {
262
+ if (secret === null) this.secret = null;
263
+ return secret;
264
+ });
265
+ return poolsFrom(this.env, await this.secret);
266
+ }
267
+ }
268
+
107
269
  /** What a line sounds like read aloud: who said it, then what. */
108
270
  export function spokenLine(handle: string, body: string): string {
109
271
  const said = body.replace(/\s+/g, " ").trim();
@@ -1 +1 @@
1
- import{t as e}from"./index-zCUbE3U5.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
1
+ import{t as e}from"./index-Qo7uZEAJ.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};