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 +20 -1
- package/dist/handles.d.ts +32 -0
- package/dist/handles.js +69 -0
- package/dist/main.js +28 -1
- package/dist/mcp.js +50 -0
- package/dist/partyline.d.ts +11 -0
- package/dist/partyline.js +26 -0
- package/dist/profile.d.ts +45 -0
- package/dist/profile.js +148 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +81 -6
- package/dist/voices.d.ts +107 -0
- package/dist/voices.js +285 -0
- package/package.json +1 -1
- package/src/handles.ts +72 -0
- package/src/main.ts +28 -1
- package/src/mcp.ts +47 -0
- package/src/partyline.ts +26 -0
- package/src/profile.ts +174 -0
- package/src/server.ts +85 -7
- package/src/voices.ts +315 -0
- package/web/dist/assets/{hls-3VKVEQE3-B34ptWVB.js → hls-3VKVEQE3-CqYm-w53.js} +1 -1
- package/web/dist/assets/index-Qo7uZEAJ.js +1 -0
- package/web/dist/assets/{mpegts-DAbMVJ3m.js → mpegts-BXa-l6Og.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dwbw4mqA.js → mpegts-LO6RVLD6-CkxdQRL_.js} +1 -1
- package/web/dist/index.html +22 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-CjxlA6ia.js +0 -1
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
|
-
|
|
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
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
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
|
-
|
|
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 } : {}),
|
package/dist/voices.d.ts
ADDED
|
@@ -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
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
|
*
|