nixamp 0.23.5 → 0.23.7
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 +28 -8
- package/dist/channels.d.ts +31 -0
- package/dist/channels.js +99 -4
- package/dist/main.js +28 -1
- package/dist/mcp.js +50 -0
- package/dist/outro.d.ts +48 -0
- package/dist/outro.js +225 -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/rtmp-in.js +6 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +41 -5
- package/dist/voices.d.ts +70 -7
- package/dist/voices.js +154 -25
- package/package.json +1 -1
- package/src/channels.ts +107 -4
- package/src/main.ts +28 -1
- package/src/mcp.ts +47 -0
- package/src/outro.ts +245 -0
- package/src/partyline.ts +3 -1
- package/src/profile.ts +174 -0
- package/src/rtmp-in.ts +5 -2
- package/src/server.ts +44 -5
- package/src/voices.ts +184 -22
- package/web/dist/assets/{hls-3VKVEQE3-Dq51v8Gv.js → hls-3VKVEQE3-CI1U7kbP.js} +1 -1
- package/web/dist/assets/index-5H3sUGHu.js +1 -0
- package/web/dist/assets/{mpegts-WAX5pHld.js → mpegts-CPOYjgRP.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dz2Fz7af.js → mpegts-LO6RVLD6-CE8YPjx1.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-zCUbE3U5.js +0 -1
package/dist/voices.d.ts
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
export type VoiceKind = "female" | "male" | "";
|
|
2
|
-
/** Kokoro
|
|
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. */
|
|
3
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
|
+
}
|
|
4
31
|
/** The word a person used for themselves, as a voice: female, male, or nothing to go on. */
|
|
5
32
|
export declare function voiceKindOf(value: unknown): VoiceKind;
|
|
6
33
|
export interface ProfileVoice {
|
|
@@ -16,18 +43,54 @@ export interface ProfileVoice {
|
|
|
16
43
|
*/
|
|
17
44
|
export declare function readProfileVoice(markdown: string): ProfileVoice;
|
|
18
45
|
/**
|
|
19
|
-
* The
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* picks
|
|
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.
|
|
23
52
|
*/
|
|
24
53
|
export declare function telnyxVoiceFor(who: {
|
|
25
54
|
userId: string;
|
|
26
55
|
voice?: string;
|
|
27
56
|
profile?: ProfileVoice | null;
|
|
28
|
-
},
|
|
29
|
-
|
|
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. */
|
|
30
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
|
+
}
|
|
31
94
|
/** What a line sounds like read aloud: who said it, then what. */
|
|
32
95
|
export declare function spokenLine(handle: string, body: string): string;
|
|
33
96
|
/**
|
package/dist/voices.js
CHANGED
|
@@ -4,21 +4,60 @@
|
|
|
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
|
-
/** Kokoro
|
|
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. */
|
|
19
58
|
export const DEFAULT_VOICES = {
|
|
20
|
-
female:
|
|
21
|
-
male:
|
|
59
|
+
female: KOKORO_FEMALE[0],
|
|
60
|
+
male: KOKORO_MALE[0],
|
|
22
61
|
};
|
|
23
62
|
/** The word a person used for themselves, as a voice: female, male, or nothing to go on. */
|
|
24
63
|
export function voiceKindOf(value) {
|
|
@@ -74,32 +113,122 @@ export function readProfileVoice(markdown) {
|
|
|
74
113
|
}
|
|
75
114
|
return found;
|
|
76
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
|
+
}
|
|
77
126
|
/**
|
|
78
|
-
* The
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* picks
|
|
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.
|
|
82
133
|
*/
|
|
83
|
-
export function telnyxVoiceFor(who,
|
|
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) {
|
|
84
138
|
const asked = (who.voice ?? "").trim();
|
|
85
|
-
if (asked.includes("."))
|
|
86
|
-
return asked;
|
|
87
139
|
const fromProfile = who.profile?.voice.trim() ?? "";
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 };
|
|
95
151
|
}
|
|
96
|
-
/** The voices to use per sex, from the environment when it says so. */
|
|
152
|
+
/** The voices to use per sex, from the environment when it says so. Kept for one-voice callers. */
|
|
97
153
|
export function voicesFromEnv(env = process.env) {
|
|
98
154
|
return {
|
|
99
155
|
female: env["NIXAMP_VOICE_FEMALE"] || DEFAULT_VOICES.female,
|
|
100
156
|
male: env["NIXAMP_VOICE_MALE"] || DEFAULT_VOICES.male,
|
|
101
157
|
};
|
|
102
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
|
+
}
|
|
103
232
|
/** What a line sounds like read aloud: who said it, then what. */
|
|
104
233
|
export function spokenLine(handle, body) {
|
|
105
234
|
const said = body.replace(/\s+/g, " ").trim();
|
package/package.json
CHANGED
package/src/channels.ts
CHANGED
|
@@ -95,6 +95,12 @@ export interface ChannelInfo {
|
|
|
95
95
|
* plain URL is read this way, and only when a policy asks for it.
|
|
96
96
|
*/
|
|
97
97
|
teed?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* When its show ended, wall clock, while the outro plays. A channel with
|
|
100
|
+
* this set is still on the air -- the picture says the stream has ended
|
|
101
|
+
* -- and closes on its own an hour later. Absent while the show is on.
|
|
102
|
+
*/
|
|
103
|
+
ended?: number;
|
|
98
104
|
/**
|
|
99
105
|
* A picture of it from somewhere else: the thumbnail the site offered
|
|
100
106
|
* for a pasted link, the logo a catalog gave a channel. A channel with
|
|
@@ -156,6 +162,13 @@ export const GIVE_UP = 5;
|
|
|
156
162
|
export const STALL = 30_000;
|
|
157
163
|
/** How long an on-demand channel stays up with nobody watching. */
|
|
158
164
|
export const IDLE = 60_000;
|
|
165
|
+
/** How long a channel plays its outro after the show, before it closes. */
|
|
166
|
+
export const OUTRO_MS = 60 * 60 * 1000;
|
|
167
|
+
/** The encode the outro is copied through: what a channel sends on the wire. */
|
|
168
|
+
const OUTRO_ENCODE: Record<"audio" | "video", string[]> = {
|
|
169
|
+
video: ["-c", "copy", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"],
|
|
170
|
+
audio: ["-c", "copy", "-f", "mp3"],
|
|
171
|
+
};
|
|
159
172
|
/** How much of what ffmpeg said to keep, for the last line when it dies. */
|
|
160
173
|
const TAIL = 2000;
|
|
161
174
|
/**
|
|
@@ -241,6 +254,16 @@ export interface ChannelOptions {
|
|
|
241
254
|
ffmpeg: string[];
|
|
242
255
|
onStart?: (info: ChannelInfo) => void;
|
|
243
256
|
onEnd?: (info: ChannelInfo) => void;
|
|
257
|
+
/**
|
|
258
|
+
* The outro: the clip a channel plays once its show is over, by kind,
|
|
259
|
+
* encoded as the wire wants it (see outro.ts). Null, or absent, means a
|
|
260
|
+
* show that ends closes its channel as it always did.
|
|
261
|
+
*/
|
|
262
|
+
outro?: (kind: "audio" | "video") => Promise<string | null>;
|
|
263
|
+
/** How long the outro plays before the channel closes. An hour. */
|
|
264
|
+
outroMs?: number;
|
|
265
|
+
/** Said once, when a show ends and the outro begins. */
|
|
266
|
+
onOutro?: (info: ChannelInfo) => void;
|
|
244
267
|
/** How long an on-demand channel outlives its last viewer. Tests shorten it. */
|
|
245
268
|
idleMs?: number;
|
|
246
269
|
/** Unsent bytes a listener may hold before it is dropped. Tests shrink it. */
|
|
@@ -272,6 +295,11 @@ export class Channel {
|
|
|
272
295
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
273
296
|
private watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
274
297
|
private stall = STALL;
|
|
298
|
+
/** Whether the outro is what is playing now: the show is over. */
|
|
299
|
+
private outroOn = false;
|
|
300
|
+
private outroTimer: ReturnType<typeof setTimeout> | null = null;
|
|
301
|
+
/** How the show was dialled, so a restart after the outro is the show again. */
|
|
302
|
+
private dialed: { source: string; encode: string[]; paced: boolean; stall: number; input: string[]; audio: string; resume: PullResume } | null = null;
|
|
275
303
|
private stderr = "";
|
|
276
304
|
/**
|
|
277
305
|
* The last few seconds, for whoever joins next.
|
|
@@ -368,6 +396,7 @@ export class Channel {
|
|
|
368
396
|
resume: PullResume = { live: true, position: 0 },
|
|
369
397
|
): void {
|
|
370
398
|
this.stall = stall;
|
|
399
|
+
if (!this.outroOn) this.dialed = { source, encode, paced, stall, input: [...input], audio, resume };
|
|
371
400
|
if (this.info.kind === "video") this.fragments = new Fragments();
|
|
372
401
|
const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
|
|
373
402
|
this.info.live = resume.live;
|
|
@@ -382,9 +411,12 @@ export class Channel {
|
|
|
382
411
|
this.info.playlistAt = at;
|
|
383
412
|
}
|
|
384
413
|
let current = list ? (list[at] as string) : source;
|
|
414
|
+
// The last entry is the end of the show, not a way back to the first:
|
|
415
|
+
// a list that played through is over, and says so with the outro.
|
|
385
416
|
this.advance = list && list.length > 1
|
|
386
417
|
? () => {
|
|
387
|
-
|
|
418
|
+
if (at + 1 >= list.length) return false;
|
|
419
|
+
at += 1;
|
|
388
420
|
this.info.playlistAt = at;
|
|
389
421
|
current = list[at] as string;
|
|
390
422
|
return true;
|
|
@@ -414,7 +446,8 @@ export class Channel {
|
|
|
414
446
|
// policy set after the channel started applies at its next restart.
|
|
415
447
|
this.throughAbort?.abort();
|
|
416
448
|
this.throughAbort = null;
|
|
417
|
-
|
|
449
|
+
// The outro is a file read by ffmpeg itself: a pipe cannot loop.
|
|
450
|
+
const through = this.outroOn ? null : this.options.through?.(this.info, from, input, audio) ?? null;
|
|
418
451
|
this.info.teed = through !== null;
|
|
419
452
|
const child = spawn(
|
|
420
453
|
command,
|
|
@@ -509,7 +542,43 @@ export class Channel {
|
|
|
509
542
|
|
|
510
543
|
this.redial = dial;
|
|
511
544
|
dial();
|
|
512
|
-
this.options.onStart?.(this.info);
|
|
545
|
+
if (!this.outroOn) this.options.onStart?.(this.info);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The show is over: the outro, then the end. A publisher's channel is
|
|
550
|
+
* told this when the publisher goes, so whoever joins in the next hour
|
|
551
|
+
* is shown that the stream has ended rather than nothing at all. Without
|
|
552
|
+
* an outro to play it is the same as close().
|
|
553
|
+
*/
|
|
554
|
+
finish(): void {
|
|
555
|
+
if (this.closing || this.outroOn) return;
|
|
556
|
+
void this.endShow();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private async endShow(): Promise<void> {
|
|
560
|
+
const kind = this.info.kind ?? "audio";
|
|
561
|
+
const old = this.child;
|
|
562
|
+
this.child = null;
|
|
563
|
+
if (this.watchdog) clearTimeout(this.watchdog);
|
|
564
|
+
this.watchdog = null;
|
|
565
|
+
if (this.timer) clearTimeout(this.timer);
|
|
566
|
+
this.timer = null;
|
|
567
|
+
old?.kill("SIGKILL");
|
|
568
|
+
const clip = this.options.outro ? await this.options.outro(kind).catch(() => null) : null;
|
|
569
|
+
if (this.closing) return;
|
|
570
|
+
if (!clip) {
|
|
571
|
+
this.close();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
this.outroOn = true;
|
|
575
|
+
this.info.ended = Date.now();
|
|
576
|
+
this.info.error = undefined;
|
|
577
|
+
this.startOver();
|
|
578
|
+
this.options.onOutro?.(this.info);
|
|
579
|
+
this.pull(clip, OUTRO_ENCODE[kind], true, this.stall, ["-stream_loop", "-1"], "", { live: true, position: 0 });
|
|
580
|
+
this.outroTimer = setTimeout(() => this.close(), this.options.outroMs ?? OUTRO_MS);
|
|
581
|
+
this.outroTimer.unref?.();
|
|
513
582
|
}
|
|
514
583
|
|
|
515
584
|
/**
|
|
@@ -522,8 +591,26 @@ export class Channel {
|
|
|
522
591
|
* strikes a dead CDN ran up an hour ago.
|
|
523
592
|
*/
|
|
524
593
|
restart(): boolean {
|
|
594
|
+
if (this.closing) return false;
|
|
595
|
+
// Started over from the outro: the show itself, from its beginning.
|
|
596
|
+
if (this.outroOn && this.dialed) {
|
|
597
|
+
this.outroOn = false;
|
|
598
|
+
delete this.info.ended;
|
|
599
|
+
if (this.outroTimer) clearTimeout(this.outroTimer);
|
|
600
|
+
this.outroTimer = null;
|
|
601
|
+
const old = this.child;
|
|
602
|
+
this.child = null;
|
|
603
|
+
old?.kill("SIGKILL");
|
|
604
|
+
this.failures = 0;
|
|
605
|
+
this.info.error = undefined;
|
|
606
|
+
this.info.playlistAt = 0;
|
|
607
|
+
this.startOver();
|
|
608
|
+
const show = this.dialed;
|
|
609
|
+
this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0 });
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
525
612
|
const dial = this.redial;
|
|
526
|
-
if (!dial
|
|
613
|
+
if (!dial) return false;
|
|
527
614
|
if (this.timer) clearTimeout(this.timer);
|
|
528
615
|
this.timer = null;
|
|
529
616
|
if (this.watchdog) clearTimeout(this.watchdog);
|
|
@@ -613,6 +700,11 @@ export class Channel {
|
|
|
613
700
|
*/
|
|
614
701
|
private dropped(sent: boolean): void {
|
|
615
702
|
if (this.closing || !this.redial) return;
|
|
703
|
+
// An outro that stopped is over; there is nothing after it.
|
|
704
|
+
if (this.outroOn) {
|
|
705
|
+
this.close();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
616
708
|
this.child = null;
|
|
617
709
|
if (this.watchdog) clearTimeout(this.watchdog);
|
|
618
710
|
this.watchdog = null;
|
|
@@ -628,6 +720,15 @@ export class Channel {
|
|
|
628
720
|
// gave nothing is skipped the same way, counted as the failure it was,
|
|
629
721
|
// so a list of dead links gives up rather than cycling for ever.
|
|
630
722
|
const moved = this.advance?.() ?? false;
|
|
723
|
+
// A show that ended: a film or a podcast that played to its end, a
|
|
724
|
+
// list whose last entry did. That is not a source that dropped, and it
|
|
725
|
+
// is not dialled again from the top; it is over, and the outro says
|
|
726
|
+
// so. A live feed that stopped is a feed that dropped, and is redialled.
|
|
727
|
+
const list = (this.info.playlist?.length ?? 0) > 0;
|
|
728
|
+
if (sent && !moved && (this.info.live === false || list)) {
|
|
729
|
+
void this.endShow();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
631
732
|
const ended = moved && sent;
|
|
632
733
|
if (ended) this.info.error = undefined;
|
|
633
734
|
else this.info.redials = (this.info.redials ?? 0) + 1;
|
|
@@ -914,6 +1015,8 @@ export class Channel {
|
|
|
914
1015
|
this.watchdog = null;
|
|
915
1016
|
if (this.idle) clearTimeout(this.idle);
|
|
916
1017
|
this.idle = null;
|
|
1018
|
+
if (this.outroTimer) clearTimeout(this.outroTimer);
|
|
1019
|
+
this.outroTimer = null;
|
|
917
1020
|
const said = lastLine(this.stderr);
|
|
918
1021
|
if (said && !this.info.error) this.info.error = said;
|
|
919
1022
|
const child = this.child;
|
package/src/main.ts
CHANGED
|
@@ -86,6 +86,8 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
86
86
|
nixamp mcp speak Model Context Protocol on stdin, for an agent
|
|
87
87
|
nixamp transcribe FILE [--say SERVER] the words in a recording, and into a trollbox
|
|
88
88
|
nixamp transcript --channel ID [--follow] what a channel is saying, as it says it
|
|
89
|
+
nixamp profile [--handle H] [--voice V] [--profile URL] who the rooms know you as
|
|
90
|
+
nixamp voices the voices a line is read in on the phone
|
|
89
91
|
nixamp opendir list|add|remove folders found on the web, published for everyone
|
|
90
92
|
nixamp update [version] re-run the installer, keeping your choices
|
|
91
93
|
nixamp uninstall [--yes] remove everything the installer created
|
|
@@ -268,7 +270,8 @@ It offers the watch party tools: list them, read one, put one on the air,
|
|
|
268
270
|
say where playback is, end it. And the room tools: transcribe a recording
|
|
269
271
|
(transcribe_audio, which can post the words straight into a trollbox), say a
|
|
270
272
|
line in a room (trollbox_say), read a room (trollbox_read), read what a
|
|
271
|
-
channel is saying (transcript_read)
|
|
273
|
+
channel is saying (transcript_read), and who you are in the rooms and how
|
|
274
|
+
you sound on the phone (profile_get, profile_set, voices_list). It acts as
|
|
272
275
|
whoever this machine is signed in as, so \`nixamp login\` (or NIXAMP_TOKEN)
|
|
273
276
|
comes first.
|
|
274
277
|
|
|
@@ -289,6 +292,20 @@ sign-in (\`nixamp login\`) and nothing else. Up to a minute at a time.
|
|
|
289
292
|
|
|
290
293
|
The same ear is behind the microphone button in every nixamp.com trollbox,
|
|
291
294
|
and behind the transcribe_audio tool of \`nixamp mcp\`.
|
|
295
|
+
`,
|
|
296
|
+
profile: `nixamp profile — who the rooms know you as.
|
|
297
|
+
|
|
298
|
+
nixamp profile your handle, voice and OpenProfile
|
|
299
|
+
nixamp profile --handle chovy the name on every line you say
|
|
300
|
+
nixamp profile --voice female the voice your lines are read in on the phone:
|
|
301
|
+
female, male, any, or a voice id (see \`nixamp voices\`)
|
|
302
|
+
nixamp profile --profile URL your OpenProfile.md; its Voice, Gender or Pronouns
|
|
303
|
+
decide the voice when you set none here
|
|
304
|
+
nixamp voices the voices this nixamp.com reads lines in
|
|
305
|
+
|
|
306
|
+
When somebody is on the phone in a live room, every trollbox line is read to
|
|
307
|
+
them in the author's voice: the one set here, else the OpenProfile's, else
|
|
308
|
+
one picked for the account and kept. Two people in a room are two voices.
|
|
292
309
|
`,
|
|
293
310
|
transcript: `nixamp transcript — what a channel is saying, written down.
|
|
294
311
|
|
|
@@ -502,6 +519,16 @@ export async function main(): Promise<void> {
|
|
|
502
519
|
process.exitCode = await party(rest);
|
|
503
520
|
return;
|
|
504
521
|
}
|
|
522
|
+
if (first === "profile" || first === "persona") {
|
|
523
|
+
const { profile } = await import("./profile.ts");
|
|
524
|
+
process.exitCode = await profile(rest);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (first === "voices") {
|
|
528
|
+
const { voices } = await import("./profile.ts");
|
|
529
|
+
process.exitCode = await voices(rest);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
505
532
|
if (first === "transcript" || first === "captions") {
|
|
506
533
|
const { transcript } = await import("./transcript.ts");
|
|
507
534
|
process.exitCode = await transcript(rest);
|
package/src/mcp.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { clock, type PartyRow } from "./party.ts";
|
|
|
22
22
|
import { readSession } from "./session.ts";
|
|
23
23
|
import { askToHear, wavOf } from "./transcribe.ts";
|
|
24
24
|
import { readTranscript } from "./transcript.ts";
|
|
25
|
+
import { personaLines, readPersona, readVoices, writePersona } from "./profile.ts";
|
|
25
26
|
|
|
26
27
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
27
28
|
|
|
@@ -142,6 +143,29 @@ export const TOOLS: ToolDefinition[] = [
|
|
|
142
143
|
required: ["url"],
|
|
143
144
|
},
|
|
144
145
|
},
|
|
146
|
+
{
|
|
147
|
+
name: "profile_get",
|
|
148
|
+
description: "Who the rooms know this account as: its handle, the voice its trollbox lines are read in on the phone, the OpenProfile URL, and the voice that would be used right now.",
|
|
149
|
+
inputSchema: { type: "object", properties: {} },
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "profile_set",
|
|
153
|
+
description:
|
|
154
|
+
"Set this account's handle, the voice its lines are read in on the phone (female, male, any, or a voice id from voices_list), and/or its OpenProfile URL (whose Voice, Gender or Pronouns pick the voice when none is set). Any one may be given alone.",
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: "object",
|
|
157
|
+
properties: {
|
|
158
|
+
handle: { ...STRING, description: "Letters, digits and hyphens, 2 to 30 characters." },
|
|
159
|
+
voice: { ...STRING, description: "female, male, any, or a voice id such as ElevenLabs.pNInz6obpgDQGcFmaJgB." },
|
|
160
|
+
profile: { ...STRING, description: "The URL of an OpenProfile.md, or an empty string to clear it." },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "voices_list",
|
|
166
|
+
description: "The voices trollbox lines can be read in on the phone: the provider in use and the women's and men's voice ids.",
|
|
167
|
+
inputSchema: { type: "object", properties: {} },
|
|
168
|
+
},
|
|
145
169
|
{
|
|
146
170
|
name: "trollbox_read",
|
|
147
171
|
description: "The recent lines in a live room's trollbox, oldest first: who said what, and when.",
|
|
@@ -335,6 +359,29 @@ export async function callTool(name: string, args: Record<string, unknown>, opti
|
|
|
335
359
|
return text(got.answer.recent.map((line) => `${new Date(line.at).toISOString()} ${line.text}`).join("\n"));
|
|
336
360
|
}
|
|
337
361
|
|
|
362
|
+
if (name === "profile_get") {
|
|
363
|
+
const answer = await readPersona(session, send);
|
|
364
|
+
if (!answer.ok) return failed(answer.error);
|
|
365
|
+
return text(personaLines(answer.body).join("\n"));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (name === "profile_set") {
|
|
369
|
+
const wanted: { handle?: string; voice?: string; profile?: string } = {};
|
|
370
|
+
if (typeof args["handle"] === "string") wanted.handle = args["handle"];
|
|
371
|
+
if (typeof args["voice"] === "string") wanted.voice = args["voice"];
|
|
372
|
+
if (typeof args["profile"] === "string") wanted.profile = args["profile"];
|
|
373
|
+
if (Object.keys(wanted).length === 0) return failed("Set what? Pass handle, voice and/or profile.");
|
|
374
|
+
const answer = await writePersona(session, wanted, send);
|
|
375
|
+
if (!answer.ok) return failed(answer.error);
|
|
376
|
+
return text(personaLines(answer.body).join("\n"));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (name === "voices_list") {
|
|
380
|
+
const answer = await readVoices(session, send);
|
|
381
|
+
if (!answer.ok) return failed(answer.error);
|
|
382
|
+
return text([`Voices: ${answer.body.provider}.`, "Women:", ...answer.body.female.map((one) => ` ${one}`), "Men:", ...answer.body.male.map((one) => ` ${one}`)].join("\n"));
|
|
383
|
+
}
|
|
384
|
+
|
|
338
385
|
if (name === "trollbox_read") {
|
|
339
386
|
if (!server) return failed("Which room? Pass the server's address.");
|
|
340
387
|
const url = new URL(`${site}/api/v1/trollbox`);
|