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 CHANGED
@@ -297,6 +297,18 @@ something else.
297
297
  Entries expire a few minutes after a stream stops renewing, so the list is
298
298
  always what is actually live.
299
299
 
300
+ **A live that ends says so.** A film or a podcast that plays to its end, a
301
+ list whose last entry did, or a publisher who stopped, used to start again
302
+ from the top for ever. Now the channel plays its outro: five seconds of
303
+ "THIS LIVE STREAM HAS ENDED" on the plate with the mark (a soft chime, on a
304
+ channel with no picture), looped for an hour, so whoever joins late is told
305
+ by the picture and by the page (an ENDED chip and a line in the Log), and
306
+ the room's trollbox stays open. Then the channel closes on its own. The
307
+ clip is drawn by the server itself with its ffmpeg the first time it is
308
+ needed and kept beside the keys; **Start over** on the channel brings the
309
+ show back from its beginning. A live feed that drops mid-stream is still
310
+ dialled again, as before: only a clean end is an end.
311
+
300
312
  ## The trollbox, and saying a line out loud
301
313
 
302
314
  Every live room has a trollbox: the chat for whoever has joined that stream,
@@ -314,14 +326,22 @@ which case the words wait in the box for Send. A wrong line is taken down
314
326
  with its ✕.
315
327
 
316
328
  **On the phone, too.** Every live room has a six-digit code on the party
317
- line (see below), and when anybody is on the phone in a room, each trollbox
318
- line is read aloud to them: "chovy says: …", in a voice that is theirs as
319
- far as a machine can manage. The Account panel sets it: a woman's voice, a
320
- man's, any, or a provider voice id; or an OpenProfile URL, whose `Voice`,
321
- `Gender` or `Pronouns` decide. Nothing set picks one from the account, so
322
- the same person is always the same voice. The voices are Telnyx's Kokoro
323
- ones, open weights, no bill beyond the call; `NIXAMP_VOICE_FEMALE` and
324
- `NIXAMP_VOICE_MALE` swap in others (an ElevenLabs id, say).
329
+ line (see below), and only when somebody is on the phone in a room, each
330
+ trollbox line is read aloud to them: "chovy says: …", in a voice that is
331
+ theirs as far as a machine can manage, and different from everybody
332
+ else's in the room. The Account panel (or `nixamp profile`, or the
333
+ `profile_set` MCP tool) sets it: a woman's voice, a man's, any, or a voice
334
+ id; or an OpenProfile URL, whose `Voice`, `Gender` or `Pronouns` decide.
335
+ Given a sex, the account picks one voice from that sex's pool and keeps
336
+ it; given nothing, one from the whole pool. `nixamp voices` lists them.
337
+
338
+ Two pools. Telnyx's Kokoro voices are an open-weights model with no bill
339
+ beyond the call: eleven women, eight men. ElevenLabs reads better and bills
340
+ per character: when the Telnyx account holds an integration secret named
341
+ `elevenlabs` with the ElevenLabs key, nixamp.com finds it on its own and
342
+ uses ElevenLabs' premade voices by their labelled gender; `NIXAMP_TTS=kokoro`
343
+ keeps the free ones regardless. `NIXAMP_VOICES_FEMALE` / `NIXAMP_VOICES_MALE`
344
+ (comma lists of Telnyx voice ids) replace either pool outright.
325
345
  The ear is [Whisper](https://github.com/openai/whisper) run through
326
346
  [Transformers.js](https://github.com/huggingface/transformers.js), an
327
347
  Apache-2.0 library carrying MIT-licensed models, on nixamp.com's own CPU.
@@ -80,6 +80,12 @@ export interface ChannelInfo {
80
80
  * plain URL is read this way, and only when a policy asks for it.
81
81
  */
82
82
  teed?: boolean;
83
+ /**
84
+ * When its show ended, wall clock, while the outro plays. A channel with
85
+ * this set is still on the air -- the picture says the stream has ended
86
+ * -- and closes on its own an hour later. Absent while the show is on.
87
+ */
88
+ ended?: number;
83
89
  /**
84
90
  * A picture of it from somewhere else: the thumbnail the site offered
85
91
  * for a pasted link, the logo a catalog gave a channel. A channel with
@@ -136,6 +142,8 @@ export declare const GIVE_UP = 5;
136
142
  export declare const STALL = 30000;
137
143
  /** How long an on-demand channel stays up with nobody watching. */
138
144
  export declare const IDLE = 60000;
145
+ /** How long a channel plays its outro after the show, before it closes. */
146
+ export declare const OUTRO_MS: number;
139
147
  /**
140
148
  * How much of the recent stream a newcomer is handed. About six seconds of
141
149
  * 720p television, and a couple of seconds of 192k MP3: enough to play
@@ -181,6 +189,16 @@ export interface ChannelOptions {
181
189
  ffmpeg: string[];
182
190
  onStart?: (info: ChannelInfo) => void;
183
191
  onEnd?: (info: ChannelInfo) => void;
192
+ /**
193
+ * The outro: the clip a channel plays once its show is over, by kind,
194
+ * encoded as the wire wants it (see outro.ts). Null, or absent, means a
195
+ * show that ends closes its channel as it always did.
196
+ */
197
+ outro?: (kind: "audio" | "video") => Promise<string | null>;
198
+ /** How long the outro plays before the channel closes. An hour. */
199
+ outroMs?: number;
200
+ /** Said once, when a show ends and the outro begins. */
201
+ onOutro?: (info: ChannelInfo) => void;
184
202
  /** How long an on-demand channel outlives its last viewer. Tests shorten it. */
185
203
  idleMs?: number;
186
204
  /** Unsent bytes a listener may hold before it is dropped. Tests shrink it. */
@@ -214,6 +232,11 @@ export declare class Channel {
214
232
  /** Fires when a pulled source has said nothing for STALL. */
215
233
  private watchdog;
216
234
  private stall;
235
+ /** Whether the outro is what is playing now: the show is over. */
236
+ private outroOn;
237
+ private outroTimer;
238
+ /** How the show was dialled, so a restart after the outro is the show again. */
239
+ private dialed;
217
240
  private stderr;
218
241
  /**
219
242
  * The last few seconds, for whoever joins next.
@@ -259,6 +282,14 @@ export declare class Channel {
259
282
  * in it is not a room anybody can be invited to.
260
283
  */
261
284
  pull(source: string, encode: string[], paced?: boolean, stall?: number, input?: string[], audio?: string, resume?: PullResume): void;
285
+ /**
286
+ * The show is over: the outro, then the end. A publisher's channel is
287
+ * told this when the publisher goes, so whoever joins in the next hour
288
+ * is shown that the stream has ended rather than nothing at all. Without
289
+ * an outro to play it is the same as close().
290
+ */
291
+ finish(): void;
292
+ private endShow;
262
293
  /**
263
294
  * Start the source over, now.
264
295
  *
package/dist/channels.js CHANGED
@@ -40,6 +40,13 @@ export const GIVE_UP = 5;
40
40
  export const STALL = 30_000;
41
41
  /** How long an on-demand channel stays up with nobody watching. */
42
42
  export const IDLE = 60_000;
43
+ /** How long a channel plays its outro after the show, before it closes. */
44
+ export const OUTRO_MS = 60 * 60 * 1000;
45
+ /** The encode the outro is copied through: what a channel sends on the wire. */
46
+ const OUTRO_ENCODE = {
47
+ video: ["-c", "copy", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"],
48
+ audio: ["-c", "copy", "-f", "mp3"],
49
+ };
43
50
  /** How much of what ffmpeg said to keep, for the last line when it dies. */
44
51
  const TAIL = 2000;
45
52
  /**
@@ -140,6 +147,11 @@ export class Channel {
140
147
  /** Fires when a pulled source has said nothing for STALL. */
141
148
  watchdog = null;
142
149
  stall = STALL;
150
+ /** Whether the outro is what is playing now: the show is over. */
151
+ outroOn = false;
152
+ outroTimer = null;
153
+ /** How the show was dialled, so a restart after the outro is the show again. */
154
+ dialed = null;
143
155
  stderr = "";
144
156
  /**
145
157
  * The last few seconds, for whoever joins next.
@@ -219,6 +231,8 @@ export class Channel {
219
231
  */
220
232
  pull(source, encode, paced = true, stall = STALL, input = [], audio = "", resume = { live: true, position: 0 }) {
221
233
  this.stall = stall;
234
+ if (!this.outroOn)
235
+ this.dialed = { source, encode, paced, stall, input: [...input], audio, resume };
222
236
  if (this.info.kind === "video")
223
237
  this.fragments = new Fragments();
224
238
  const [command, ...prefix] = this.options.ffmpeg;
@@ -235,9 +249,13 @@ export class Channel {
235
249
  this.info.playlistAt = at;
236
250
  }
237
251
  let current = list ? list[at] : source;
252
+ // The last entry is the end of the show, not a way back to the first:
253
+ // a list that played through is over, and says so with the outro.
238
254
  this.advance = list && list.length > 1
239
255
  ? () => {
240
- at = (at + 1) % list.length;
256
+ if (at + 1 >= list.length)
257
+ return false;
258
+ at += 1;
241
259
  this.info.playlistAt = at;
242
260
  current = list[at];
243
261
  return true;
@@ -267,7 +285,8 @@ export class Channel {
267
285
  // policy set after the channel started applies at its next restart.
268
286
  this.throughAbort?.abort();
269
287
  this.throughAbort = null;
270
- const through = this.options.through?.(this.info, from, input, audio) ?? null;
288
+ // The outro is a file read by ffmpeg itself: a pipe cannot loop.
289
+ const through = this.outroOn ? null : this.options.through?.(this.info, from, input, audio) ?? null;
271
290
  this.info.teed = through !== null;
272
291
  const child = spawn(command, [
273
292
  ...prefix,
@@ -362,7 +381,46 @@ export class Channel {
362
381
  };
363
382
  this.redial = dial;
364
383
  dial();
365
- this.options.onStart?.(this.info);
384
+ if (!this.outroOn)
385
+ this.options.onStart?.(this.info);
386
+ }
387
+ /**
388
+ * The show is over: the outro, then the end. A publisher's channel is
389
+ * told this when the publisher goes, so whoever joins in the next hour
390
+ * is shown that the stream has ended rather than nothing at all. Without
391
+ * an outro to play it is the same as close().
392
+ */
393
+ finish() {
394
+ if (this.closing || this.outroOn)
395
+ return;
396
+ void this.endShow();
397
+ }
398
+ async endShow() {
399
+ const kind = this.info.kind ?? "audio";
400
+ const old = this.child;
401
+ this.child = null;
402
+ if (this.watchdog)
403
+ clearTimeout(this.watchdog);
404
+ this.watchdog = null;
405
+ if (this.timer)
406
+ clearTimeout(this.timer);
407
+ this.timer = null;
408
+ old?.kill("SIGKILL");
409
+ const clip = this.options.outro ? await this.options.outro(kind).catch(() => null) : null;
410
+ if (this.closing)
411
+ return;
412
+ if (!clip) {
413
+ this.close();
414
+ return;
415
+ }
416
+ this.outroOn = true;
417
+ this.info.ended = Date.now();
418
+ this.info.error = undefined;
419
+ this.startOver();
420
+ this.options.onOutro?.(this.info);
421
+ this.pull(clip, OUTRO_ENCODE[kind], true, this.stall, ["-stream_loop", "-1"], "", { live: true, position: 0 });
422
+ this.outroTimer = setTimeout(() => this.close(), this.options.outroMs ?? OUTRO_MS);
423
+ this.outroTimer.unref?.();
366
424
  }
367
425
  /**
368
426
  * Start the source over, now.
@@ -374,8 +432,28 @@ export class Channel {
374
432
  * strikes a dead CDN ran up an hour ago.
375
433
  */
376
434
  restart() {
435
+ if (this.closing)
436
+ return false;
437
+ // Started over from the outro: the show itself, from its beginning.
438
+ if (this.outroOn && this.dialed) {
439
+ this.outroOn = false;
440
+ delete this.info.ended;
441
+ if (this.outroTimer)
442
+ clearTimeout(this.outroTimer);
443
+ this.outroTimer = null;
444
+ const old = this.child;
445
+ this.child = null;
446
+ old?.kill("SIGKILL");
447
+ this.failures = 0;
448
+ this.info.error = undefined;
449
+ this.info.playlistAt = 0;
450
+ this.startOver();
451
+ const show = this.dialed;
452
+ this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0 });
453
+ return true;
454
+ }
377
455
  const dial = this.redial;
378
- if (!dial || this.closing)
456
+ if (!dial)
379
457
  return false;
380
458
  if (this.timer)
381
459
  clearTimeout(this.timer);
@@ -469,6 +547,11 @@ export class Channel {
469
547
  dropped(sent) {
470
548
  if (this.closing || !this.redial)
471
549
  return;
550
+ // An outro that stopped is over; there is nothing after it.
551
+ if (this.outroOn) {
552
+ this.close();
553
+ return;
554
+ }
472
555
  this.child = null;
473
556
  if (this.watchdog)
474
557
  clearTimeout(this.watchdog);
@@ -486,6 +569,15 @@ export class Channel {
486
569
  // gave nothing is skipped the same way, counted as the failure it was,
487
570
  // so a list of dead links gives up rather than cycling for ever.
488
571
  const moved = this.advance?.() ?? false;
572
+ // A show that ended: a film or a podcast that played to its end, a
573
+ // list whose last entry did. That is not a source that dropped, and it
574
+ // is not dialled again from the top; it is over, and the outro says
575
+ // so. A live feed that stopped is a feed that dropped, and is redialled.
576
+ const list = (this.info.playlist?.length ?? 0) > 0;
577
+ if (sent && !moved && (this.info.live === false || list)) {
578
+ void this.endShow();
579
+ return;
580
+ }
489
581
  const ended = moved && sent;
490
582
  if (ended)
491
583
  this.info.error = undefined;
@@ -786,6 +878,9 @@ export class Channel {
786
878
  if (this.idle)
787
879
  clearTimeout(this.idle);
788
880
  this.idle = null;
881
+ if (this.outroTimer)
882
+ clearTimeout(this.outroTimer);
883
+ this.outroTimer = null;
789
884
  const said = lastLine(this.stderr);
790
885
  if (said && !this.info.error)
791
886
  this.info.error = said;
package/dist/main.js CHANGED
@@ -62,6 +62,8 @@ const HELP = `nixamp — it really whips the terminal's ass.
62
62
  nixamp mcp speak Model Context Protocol on stdin, for an agent
63
63
  nixamp transcribe FILE [--say SERVER] the words in a recording, and into a trollbox
64
64
  nixamp transcript --channel ID [--follow] what a channel is saying, as it says it
65
+ nixamp profile [--handle H] [--voice V] [--profile URL] who the rooms know you as
66
+ nixamp voices the voices a line is read in on the phone
65
67
  nixamp opendir list|add|remove folders found on the web, published for everyone
66
68
  nixamp update [version] re-run the installer, keeping your choices
67
69
  nixamp uninstall [--yes] remove everything the installer created
@@ -242,7 +244,8 @@ It offers the watch party tools: list them, read one, put one on the air,
242
244
  say where playback is, end it. And the room tools: transcribe a recording
243
245
  (transcribe_audio, which can post the words straight into a trollbox), say a
244
246
  line in a room (trollbox_say), read a room (trollbox_read), read what a
245
- channel is saying (transcript_read). It acts as
247
+ channel is saying (transcript_read), and who you are in the rooms and how
248
+ you sound on the phone (profile_get, profile_set, voices_list). It acts as
246
249
  whoever this machine is signed in as, so \`nixamp login\` (or NIXAMP_TOKEN)
247
250
  comes first.
248
251
 
@@ -263,6 +266,20 @@ sign-in (\`nixamp login\`) and nothing else. Up to a minute at a time.
263
266
 
264
267
  The same ear is behind the microphone button in every nixamp.com trollbox,
265
268
  and behind the transcribe_audio tool of \`nixamp mcp\`.
269
+ `,
270
+ profile: `nixamp profile — who the rooms know you as.
271
+
272
+ nixamp profile your handle, voice and OpenProfile
273
+ nixamp profile --handle chovy the name on every line you say
274
+ nixamp profile --voice female the voice your lines are read in on the phone:
275
+ female, male, any, or a voice id (see \`nixamp voices\`)
276
+ nixamp profile --profile URL your OpenProfile.md; its Voice, Gender or Pronouns
277
+ decide the voice when you set none here
278
+ nixamp voices the voices this nixamp.com reads lines in
279
+
280
+ When somebody is on the phone in a live room, every trollbox line is read to
281
+ them in the author's voice: the one set here, else the OpenProfile's, else
282
+ one picked for the account and kept. Two people in a room are two voices.
266
283
  `,
267
284
  transcript: `nixamp transcript — what a channel is saying, written down.
268
285
 
@@ -473,6 +490,16 @@ export async function main() {
473
490
  process.exitCode = await party(rest);
474
491
  return;
475
492
  }
493
+ if (first === "profile" || first === "persona") {
494
+ const { profile } = await import("./profile.js");
495
+ process.exitCode = await profile(rest);
496
+ return;
497
+ }
498
+ if (first === "voices") {
499
+ const { voices } = await import("./profile.js");
500
+ process.exitCode = await voices(rest);
501
+ return;
502
+ }
476
503
  if (first === "transcript" || first === "captions") {
477
504
  const { transcript } = await import("./transcript.js");
478
505
  process.exitCode = await transcript(rest);
package/dist/mcp.js CHANGED
@@ -22,6 +22,7 @@ import { clock } from "./party.js";
22
22
  import { readSession } from "./session.js";
23
23
  import { askToHear, wavOf } from "./transcribe.js";
24
24
  import { readTranscript } from "./transcript.js";
25
+ import { personaLines, readPersona, readVoices, writePersona } from "./profile.js";
25
26
  export const PROTOCOL_VERSION = "2025-06-18";
26
27
  const STRING = { type: "string" };
27
28
  export const TOOLS = [
@@ -119,6 +120,28 @@ export const TOOLS = [
119
120
  required: ["url"],
120
121
  },
121
122
  },
123
+ {
124
+ name: "profile_get",
125
+ 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.",
126
+ inputSchema: { type: "object", properties: {} },
127
+ },
128
+ {
129
+ name: "profile_set",
130
+ description: "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.",
131
+ inputSchema: {
132
+ type: "object",
133
+ properties: {
134
+ handle: { ...STRING, description: "Letters, digits and hyphens, 2 to 30 characters." },
135
+ voice: { ...STRING, description: "female, male, any, or a voice id such as ElevenLabs.pNInz6obpgDQGcFmaJgB." },
136
+ profile: { ...STRING, description: "The URL of an OpenProfile.md, or an empty string to clear it." },
137
+ },
138
+ },
139
+ },
140
+ {
141
+ name: "voices_list",
142
+ 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.",
143
+ inputSchema: { type: "object", properties: {} },
144
+ },
122
145
  {
123
146
  name: "trollbox_read",
124
147
  description: "The recent lines in a live room's trollbox, oldest first: who said what, and when.",
@@ -289,6 +312,33 @@ export async function callTool(name, args, options = {}) {
289
312
  }
290
313
  return text(got.answer.recent.map((line) => `${new Date(line.at).toISOString()} ${line.text}`).join("\n"));
291
314
  }
315
+ if (name === "profile_get") {
316
+ const answer = await readPersona(session, send);
317
+ if (!answer.ok)
318
+ return failed(answer.error);
319
+ return text(personaLines(answer.body).join("\n"));
320
+ }
321
+ if (name === "profile_set") {
322
+ const wanted = {};
323
+ if (typeof args["handle"] === "string")
324
+ wanted.handle = args["handle"];
325
+ if (typeof args["voice"] === "string")
326
+ wanted.voice = args["voice"];
327
+ if (typeof args["profile"] === "string")
328
+ wanted.profile = args["profile"];
329
+ if (Object.keys(wanted).length === 0)
330
+ return failed("Set what? Pass handle, voice and/or profile.");
331
+ const answer = await writePersona(session, wanted, send);
332
+ if (!answer.ok)
333
+ return failed(answer.error);
334
+ return text(personaLines(answer.body).join("\n"));
335
+ }
336
+ if (name === "voices_list") {
337
+ const answer = await readVoices(session, send);
338
+ if (!answer.ok)
339
+ return failed(answer.error);
340
+ return text([`Voices: ${answer.body.provider}.`, "Women:", ...answer.body.female.map((one) => ` ${one}`), "Men:", ...answer.body.male.map((one) => ` ${one}`)].join("\n"));
341
+ }
292
342
  if (name === "trollbox_read") {
293
343
  if (!server)
294
344
  return failed("Which room? Pass the server's address.");
@@ -0,0 +1,48 @@
1
+ /** How long the outro plays after a show ends, before the channel closes. */
2
+ export declare const OUTRO_MS: number;
3
+ /** The clip's length; it loops. */
4
+ export declare const OUTRO_SECONDS = 5;
5
+ /** Bumped when the drawing changes, so an old clip on disk is drawn again. */
6
+ export declare const OUTRO_VERSION = 1;
7
+ export interface Rgba {
8
+ r: number;
9
+ g: number;
10
+ b: number;
11
+ a: number;
12
+ }
13
+ /** A canvas of straight RGBA bytes. */
14
+ export declare class Bitmap {
15
+ readonly width: number;
16
+ readonly height: number;
17
+ readonly pixels: Uint8Array;
18
+ constructor(width: number, height: number);
19
+ fill(color: Rgba): void;
20
+ rect(x: number, y: number, w: number, h: number, color: Rgba): void;
21
+ private set;
22
+ }
23
+ /** Encode as a non-interlaced 8-bit RGBA PNG. */
24
+ export declare function encodePng(bitmap: Bitmap): Buffer;
25
+ export declare function textWidth(text: string, cell: number): number;
26
+ export declare function drawText(bitmap: Bitmap, text: string, x: number, y: number, cell: number, color: Rgba): void;
27
+ /** The picture: the mark above the words, on the plate. 1280x720. */
28
+ export declare function drawOutro(width?: number, height?: number): Bitmap;
29
+ /** The encode a channel copies an outro through: what a channel sends on the wire. */
30
+ export declare const OUTRO_ENCODE: Record<"audio" | "video", string[]>;
31
+ export interface OutroOptions {
32
+ ffmpeg: string[];
33
+ /** Where the clips are kept. */
34
+ dir: string;
35
+ onEvent?: (message: string) => void;
36
+ }
37
+ /**
38
+ * The clips, drawn on first use and kept. `clip(kind)` answers the path,
39
+ * or null when there is no ffmpeg to make one, in which case a channel
40
+ * that ends simply closes as it used to.
41
+ */
42
+ export declare class Outro {
43
+ private readonly options;
44
+ private made;
45
+ constructor(options: OutroOptions);
46
+ clip(kind: "audio" | "video"): Promise<string | null>;
47
+ private make;
48
+ }