nixamp 0.23.5 → 0.23.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -8
- package/dist/main.js +28 -1
- package/dist/mcp.js +50 -0
- package/dist/partyline.d.ts +3 -1
- package/dist/partyline.js +3 -1
- package/dist/profile.d.ts +45 -0
- package/dist/profile.js +148 -0
- package/dist/server.d.ts +3 -1
- package/dist/server.js +32 -5
- package/dist/voices.d.ts +70 -7
- package/dist/voices.js +154 -25
- package/package.json +1 -1
- package/src/main.ts +28 -1
- package/src/mcp.ts +47 -0
- package/src/partyline.ts +3 -1
- package/src/profile.ts +174 -0
- package/src/server.ts +35 -5
- package/src/voices.ts +184 -22
- package/web/dist/assets/{hls-3VKVEQE3-Dq51v8Gv.js → hls-3VKVEQE3-CqYm-w53.js} +1 -1
- package/web/dist/assets/{index-zCUbE3U5.js → index-Qo7uZEAJ.js} +1 -1
- package/web/dist/assets/{mpegts-WAX5pHld.js → mpegts-BXa-l6Og.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dz2Fz7af.js → mpegts-LO6RVLD6-CkxdQRL_.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
package/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
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
* the call
|
|
15
|
-
*
|
|
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
|
|
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:
|
|
24
|
-
male:
|
|
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
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* picks
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
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-
|
|
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};
|