nixamp 0.23.6 → 0.24.0

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.
Files changed (57) hide show
  1. package/README.md +91 -3
  2. package/dist/captions.d.ts +57 -10
  3. package/dist/captions.js +253 -29
  4. package/dist/channels.d.ts +31 -0
  5. package/dist/channels.js +99 -4
  6. package/dist/main.js +63 -19
  7. package/dist/mcp.d.ts +7 -1
  8. package/dist/mcp.js +159 -24
  9. package/dist/outro.d.ts +48 -0
  10. package/dist/outro.js +225 -0
  11. package/dist/rtmp-in.js +6 -1
  12. package/dist/server.d.ts +11 -0
  13. package/dist/server.js +362 -8
  14. package/dist/speech.d.ts +21 -3
  15. package/dist/speech.js +65 -8
  16. package/dist/transcribe.d.ts +70 -0
  17. package/dist/transcribe.js +337 -41
  18. package/dist/transcript-client.d.ts +81 -0
  19. package/dist/transcript-client.js +76 -0
  20. package/dist/transcript.d.ts +7 -2
  21. package/dist/transcript.js +73 -5
  22. package/dist/transcripts.d.ts +135 -0
  23. package/dist/transcripts.js +384 -0
  24. package/dist/translate-cli.d.ts +16 -0
  25. package/dist/translate-cli.js +97 -0
  26. package/dist/translate-jobs.d.ts +39 -0
  27. package/dist/translate-jobs.js +120 -0
  28. package/dist/translate.d.ts +78 -0
  29. package/dist/translate.js +241 -0
  30. package/dist/warm.d.ts +1 -0
  31. package/dist/warm.js +40 -0
  32. package/package.json +1 -1
  33. package/src/captions.ts +276 -29
  34. package/src/channels.ts +107 -4
  35. package/src/main.ts +63 -19
  36. package/src/mcp.ts +156 -21
  37. package/src/outro.ts +245 -0
  38. package/src/rtmp-in.ts +5 -2
  39. package/src/server.ts +361 -8
  40. package/src/speech.ts +103 -13
  41. package/src/transcribe.ts +374 -41
  42. package/src/transcript-client.ts +147 -0
  43. package/src/transcript.ts +76 -4
  44. package/src/transcripts.ts +462 -0
  45. package/src/translate-cli.ts +111 -0
  46. package/src/translate-jobs.ts +132 -0
  47. package/src/translate.ts +276 -0
  48. package/src/warm.ts +43 -0
  49. package/web/dist/assets/{hls-3VKVEQE3-CqYm-w53.js → hls-3VKVEQE3-Dtl-3mpW.js} +1 -1
  50. package/web/dist/assets/index-B0h4Nexr.js +1 -0
  51. package/web/dist/assets/index-BTGV3Pi5.css +1 -0
  52. package/web/dist/assets/{mpegts-BXa-l6Og.js → mpegts-BJC48bFV.js} +1 -1
  53. package/web/dist/assets/{mpegts-LO6RVLD6-CkxdQRL_.js → mpegts-LO6RVLD6-CUIAB9k3.js} +1 -1
  54. package/web/dist/index.html +12 -8
  55. package/web/dist/sw.js +6 -6
  56. package/web/dist/assets/index-46pwGn5-.css +0 -1
  57. package/web/dist/assets/index-Qo7uZEAJ.js +0 -1
@@ -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
+ }
package/dist/outro.js ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The outro: what a channel plays once its show is over.
3
+ *
4
+ * A live that ended used to start again from the top, for ever, because a
5
+ * source that finished cleanly was dialled like a source that dropped. Now
6
+ * it plays this: five seconds of "THIS LIVE STREAM HAS ENDED" on the
7
+ * plate with the mark, looped for an hour, so whoever joins late is told
8
+ * so by the picture and not by a blank panel. Then the channel closes.
9
+ *
10
+ * Drawn here, never committed as a file, the way the app's icons are:
11
+ * a bitmap, a 5x7 pixel face, and ffmpeg to make a clip of it. Encoded
12
+ * exactly as a channel is sent on the wire (H.264 and AAC for a picture,
13
+ * MP3 for sound alone) so the channel can copy it through without knowing
14
+ * how its own source was encoded. Made once per server and kept beside the
15
+ * keys; a missing or stale one is drawn again.
16
+ */
17
+ import { spawnSync } from "node:child_process";
18
+ import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { deflateSync } from "node:zlib";
21
+ /** How long the outro plays after a show ends, before the channel closes. */
22
+ export const OUTRO_MS = 60 * 60 * 1000;
23
+ /** The clip's length; it loops. */
24
+ export const OUTRO_SECONDS = 5;
25
+ /** Bumped when the drawing changes, so an old clip on disk is drawn again. */
26
+ export const OUTRO_VERSION = 1;
27
+ const BACKGROUND = { r: 8, g: 12, b: 9, a: 255 };
28
+ const GREEN = { r: 74, g: 246, b: 137, a: 255 };
29
+ const DIM = { r: 34, g: 122, b: 74, a: 255 };
30
+ const MUTED = { r: 120, g: 150, b: 130, a: 255 };
31
+ const BARS = [0.30, 0.55, 0.85, 0.62, 1.0, 0.72, 0.45, 0.25];
32
+ /** A canvas of straight RGBA bytes. */
33
+ export class Bitmap {
34
+ width;
35
+ height;
36
+ pixels;
37
+ constructor(width, height) {
38
+ this.width = width;
39
+ this.height = height;
40
+ this.pixels = new Uint8Array(width * height * 4);
41
+ }
42
+ fill(color) {
43
+ for (let i = 0; i < this.pixels.length; i += 4)
44
+ this.set(i, color);
45
+ }
46
+ rect(x, y, w, h, color) {
47
+ const x0 = Math.max(0, Math.round(x));
48
+ const y0 = Math.max(0, Math.round(y));
49
+ const x1 = Math.min(this.width, Math.round(x + w));
50
+ const y1 = Math.min(this.height, Math.round(y + h));
51
+ for (let py = y0; py < y1; py++) {
52
+ for (let px = x0; px < x1; px++)
53
+ this.set((py * this.width + px) * 4, color);
54
+ }
55
+ }
56
+ set(offset, { r, g, b, a }) {
57
+ this.pixels[offset] = r;
58
+ this.pixels[offset + 1] = g;
59
+ this.pixels[offset + 2] = b;
60
+ this.pixels[offset + 3] = a;
61
+ }
62
+ }
63
+ const CRC_TABLE = (() => {
64
+ const table = new Uint32Array(256);
65
+ for (let n = 0; n < 256; n++) {
66
+ let c = n;
67
+ for (let k = 0; k < 8; k++)
68
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
69
+ table[n] = c >>> 0;
70
+ }
71
+ return table;
72
+ })();
73
+ function crc32(bytes) {
74
+ let c = 0xffffffff;
75
+ for (const byte of bytes)
76
+ c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
77
+ return (c ^ 0xffffffff) >>> 0;
78
+ }
79
+ function chunk(type, data) {
80
+ const head = Buffer.alloc(8);
81
+ head.writeUInt32BE(data.length, 0);
82
+ head.write(type, 4, "ascii");
83
+ const body = Buffer.concat([head.subarray(4), Buffer.from(data)]);
84
+ const tail = Buffer.alloc(4);
85
+ tail.writeUInt32BE(crc32(body), 0);
86
+ return Buffer.concat([head.subarray(0, 4), body, tail]);
87
+ }
88
+ /** Encode as a non-interlaced 8-bit RGBA PNG. */
89
+ export function encodePng(bitmap) {
90
+ const { width, height, pixels } = bitmap;
91
+ const stride = width * 4;
92
+ const raw = Buffer.alloc((stride + 1) * height);
93
+ for (let y = 0; y < height; y++) {
94
+ raw[y * (stride + 1)] = 0;
95
+ Buffer.from(pixels.subarray(y * stride, (y + 1) * stride)).copy(raw, y * (stride + 1) + 1);
96
+ }
97
+ const ihdr = Buffer.alloc(13);
98
+ ihdr.writeUInt32BE(width, 0);
99
+ ihdr.writeUInt32BE(height, 4);
100
+ ihdr[8] = 8;
101
+ ihdr[9] = 6;
102
+ return Buffer.concat([
103
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
104
+ chunk("IHDR", ihdr),
105
+ chunk("IDAT", deflateSync(raw, { level: 9 })),
106
+ chunk("IEND", new Uint8Array(0)),
107
+ ]);
108
+ }
109
+ /** A 5x7 pixel face: the letters the outro needs and nothing else. */
110
+ const GLYPHS = {
111
+ A: [".XXX.", "X...X", "X...X", "XXXXX", "X...X", "X...X", "X...X"],
112
+ C: [".XXXX", "X....", "X....", "X....", "X....", "X....", ".XXXX"],
113
+ D: ["XXXX.", "X...X", "X...X", "X...X", "X...X", "X...X", "XXXX."],
114
+ E: ["XXXXX", "X....", "X....", "XXXX.", "X....", "X....", "XXXXX"],
115
+ H: ["X...X", "X...X", "X...X", "XXXXX", "X...X", "X...X", "X...X"],
116
+ I: ["XXXXX", "..X..", "..X..", "..X..", "..X..", "..X..", "XXXXX"],
117
+ L: ["X....", "X....", "X....", "X....", "X....", "X....", "XXXXX"],
118
+ M: ["X...X", "XX.XX", "X.X.X", "X.X.X", "X...X", "X...X", "X...X"],
119
+ N: ["X...X", "XX..X", "X.X.X", "X..XX", "X...X", "X...X", "X...X"],
120
+ O: [".XXX.", "X...X", "X...X", "X...X", "X...X", "X...X", ".XXX."],
121
+ P: ["XXXX.", "X...X", "X...X", "XXXX.", "X....", "X....", "X...."],
122
+ R: ["XXXX.", "X...X", "X...X", "XXXX.", "X.X..", "X..X.", "X...X"],
123
+ S: [".XXXX", "X....", "X....", ".XXX.", "....X", "....X", "XXXX."],
124
+ T: ["XXXXX", "..X..", "..X..", "..X..", "..X..", "..X..", "..X.."],
125
+ V: ["X...X", "X...X", "X...X", "X...X", "X...X", ".X.X.", "..X.."],
126
+ X: ["X...X", "X...X", ".X.X.", "..X..", ".X.X.", "X...X", "X...X"],
127
+ ".": [".....", ".....", ".....", ".....", ".....", "..XX.", "..XX."],
128
+ };
129
+ export function textWidth(text, cell) {
130
+ return text.length * 6 * cell - cell;
131
+ }
132
+ export function drawText(bitmap, text, x, y, cell, color) {
133
+ let at = x;
134
+ for (const letter of text) {
135
+ const rows = GLYPHS[letter];
136
+ if (rows) {
137
+ rows.forEach((row, r) => {
138
+ for (let c = 0; c < row.length; c++)
139
+ if (row[c] === "X")
140
+ bitmap.rect(at + c * cell, y + r * cell, cell, cell, color);
141
+ });
142
+ }
143
+ at += 6 * cell;
144
+ }
145
+ }
146
+ function drawBars(bitmap, left, bottom, width, tallest, scale) {
147
+ const slot = width / BARS.length;
148
+ const bar = slot * 0.62;
149
+ BARS.forEach((value, i) => {
150
+ const height = Math.max(2, tallest * value);
151
+ const x = left + i * slot + (slot - bar) / 2;
152
+ bitmap.rect(x, bottom - height, bar, height, GREEN);
153
+ const peak = Math.max(2, scale * 0.018);
154
+ bitmap.rect(x, bottom - height - peak * 2.4, bar, peak, DIM);
155
+ });
156
+ bitmap.rect(left, bottom, width, Math.max(2, scale * 0.028), DIM);
157
+ }
158
+ /** The picture: the mark above the words, on the plate. 1280x720. */
159
+ export function drawOutro(width = 1280, height = 720) {
160
+ const bitmap = new Bitmap(width, height);
161
+ bitmap.fill(BACKGROUND);
162
+ const mark = height * 0.3;
163
+ drawBars(bitmap, (width - mark) / 2, height * 0.42, mark, mark - height * 0.02, mark * 1.8);
164
+ const line = "THIS LIVE STREAM HAS ENDED";
165
+ const cell = Math.floor(Math.min((width * 0.86) / (line.length * 6), height / 40));
166
+ drawText(bitmap, line, (width - textWidth(line, cell)) / 2, height * 0.56, cell, GREEN);
167
+ const small = Math.max(2, Math.floor(cell * 0.6));
168
+ const site = "NIXAMP.COM";
169
+ drawText(bitmap, site, (width - textWidth(site, small)) / 2, height * 0.56 + cell * 7 + small * 3, small, MUTED);
170
+ return bitmap;
171
+ }
172
+ /** The encode a channel copies an outro through: what a channel sends on the wire. */
173
+ export const OUTRO_ENCODE = {
174
+ video: ["-c", "copy", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"],
175
+ audio: ["-c", "copy", "-f", "mp3"],
176
+ };
177
+ /**
178
+ * The clips, drawn on first use and kept. `clip(kind)` answers the path,
179
+ * or null when there is no ffmpeg to make one, in which case a channel
180
+ * that ends simply closes as it used to.
181
+ */
182
+ export class Outro {
183
+ options;
184
+ made = new Map();
185
+ constructor(options) {
186
+ this.options = options;
187
+ }
188
+ clip(kind) {
189
+ let pending = this.made.get(kind);
190
+ if (!pending) {
191
+ pending = Promise.resolve().then(() => this.make(kind)).catch(() => null);
192
+ this.made.set(kind, pending);
193
+ // A clip that could not be made is tried again next time, not remembered.
194
+ void pending.then((path) => { if (path === null)
195
+ this.made.delete(kind); });
196
+ }
197
+ return pending;
198
+ }
199
+ make(kind) {
200
+ mkdirSync(this.options.dir, { recursive: true });
201
+ const path = join(this.options.dir, kind === "video" ? `outro-v${OUTRO_VERSION}.mp4` : `outro-v${OUTRO_VERSION}.mp3`);
202
+ if (existsSync(path) && statSync(path).size > 1000)
203
+ return path;
204
+ const [command, ...prefix] = this.options.ffmpeg;
205
+ if (!command)
206
+ return null;
207
+ const args = ["-hide_banner", "-loglevel", "error", "-y"];
208
+ if (kind === "video") {
209
+ const picture = join(this.options.dir, `outro-v${OUTRO_VERSION}.png`);
210
+ writeFileSync(picture, encodePng(drawOutro()));
211
+ args.push("-loop", "1", "-framerate", "30", "-i", picture, "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-t", String(OUTRO_SECONDS), "-c:v", "libx264", "-preset", "veryfast", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-r", "30", "-g", "30", "-keyint_min", "30", "-c:a", "aac", "-b:a", "64k", "-shortest", "-movflags", "+faststart", path);
212
+ }
213
+ else {
214
+ // Two soft notes, then quiet: something to hear on a channel with no picture.
215
+ args.push("-f", "lavfi", "-i", `aevalsrc=0.18*sin(2*PI*523.25*t)*exp(-2.5*t)+0.18*sin(2*PI*392*t)*exp(-2.5*max(t-0.7\\,0))*gte(t\\,0.7):s=44100:d=${OUTRO_SECONDS}`, "-c:a", "libmp3lame", "-b:a", "128k", path);
216
+ }
217
+ const run = spawnSync(command, [...prefix, ...args], { timeout: 60_000 });
218
+ if (run.error || run.status !== 0 || !existsSync(path)) {
219
+ this.options.onEvent?.(` the outro could not be made${run.stderr ? `: ${run.stderr.toString("utf8").trim().split("\n").pop() ?? ""}` : ""}`);
220
+ return null;
221
+ }
222
+ this.options.onEvent?.(` outro drawn: ${path}`);
223
+ return path;
224
+ }
225
+ }
package/dist/rtmp-in.js CHANGED
@@ -70,7 +70,12 @@ export class RtmpListeners {
70
70
  if (this.running.get(slot.port) !== child)
71
71
  return;
72
72
  this.running.delete(slot.port);
73
- channel?.close();
73
+ // The publisher went: the outro plays for whoever is still there, and
74
+ // whoever joins in the next hour, before the channel closes.
75
+ if (channel?.finish)
76
+ channel.finish();
77
+ else
78
+ channel?.close();
74
79
  if (!this.stopped)
75
80
  this.arm(slot);
76
81
  }
package/dist/server.d.ts CHANGED
@@ -32,6 +32,9 @@ import { Rooms } from "./rooms.ts";
32
32
  import { Trollbox } from "./trollbox.ts";
33
33
  import { Speech } from "./speech.ts";
34
34
  import { Captions } from "./captions.ts";
35
+ import { Translator } from "./translate.ts";
36
+ import { StoredTranslations } from "./translate-jobs.ts";
37
+ import { Transcripts } from "./transcripts.ts";
35
38
  import { Profiles, Voices } from "./voices.ts";
36
39
  import { type Codecs } from "./audio.ts";
37
40
  import { type Tools, type Track } from "./audio.ts";
@@ -699,6 +702,14 @@ export interface HandlerOptions {
699
702
  trollbox?: Trollbox;
700
703
  /** Speech to text: a line said out loud, heard here. Needs the optional model. */
701
704
  speech?: Speech;
705
+ /** Translation: texts in another language, by a model here. The same optional library. */
706
+ translator?: Translator;
707
+ /** The transcript store: what was heard, kept under the media's identity. Where the accounts are. */
708
+ transcripts?: Transcripts;
709
+ /** Stored transcripts translated, as jobs. */
710
+ translations?: StoredTranslations;
711
+ /** nixamp.com's address, for the tools reached over /mcp to call. */
712
+ site?: string;
702
713
  /** Other people's OpenProfiles, for the voice their lines are read in. */
703
714
  profiles?: Profiles;
704
715
  /** The voices lines are read in: ElevenLabs when Telnyx holds the key, Kokoro otherwise. */