nixamp 0.21.4 → 0.22.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.
- package/README.md +75 -0
- package/dist/captions.d.ts +75 -0
- package/dist/captions.js +308 -0
- package/dist/main.js +47 -2
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +134 -2
- package/dist/server.d.ts +6 -0
- package/dist/server.js +151 -1
- package/dist/speech.d.ts +96 -0
- package/dist/speech.js +311 -0
- package/dist/transcribe.d.ts +43 -0
- package/dist/transcribe.js +155 -0
- package/dist/transcript.d.ts +40 -0
- package/dist/transcript.js +120 -0
- package/package.json +5 -2
- package/src/captions.ts +334 -0
- package/src/main.ts +47 -2
- package/src/mcp.ts +145 -2
- package/src/server.ts +153 -1
- package/src/speech.ts +324 -0
- package/src/transcribe.ts +177 -0
- package/src/transcript.ts +150 -0
- package/web/dist/.well-known/openaccess.json +29 -0
- package/web/dist/assets/{hls-3VKVEQE3-RSItUqmr.js → hls-3VKVEQE3-DOUPX_xZ.js} +1 -1
- package/web/dist/assets/index-BIL7sVli.js +1 -0
- package/web/dist/assets/index-D3Is8ykj.css +1 -0
- package/web/dist/assets/{mpegts-LO6RVLD6-B5VNNeMl.js → mpegts-LO6RVLD6-C9gfFFrM.js} +1 -1
- package/web/dist/assets/{mpegts-CGsP3zeI.js → mpegts-XL4bjo7s.js} +1 -1
- package/web/dist/index.html +21 -2
- package/web/dist/sw.js +7 -6
- package/web/dist/assets/index-DMVUa6vY.js +0 -1
- package/web/dist/assets/index-jOXfym7D.css +0 -1
package/README.md
CHANGED
|
@@ -293,6 +293,81 @@ something else.
|
|
|
293
293
|
Entries expire a few minutes after a stream stops renewing, so the list is
|
|
294
294
|
always what is actually live.
|
|
295
295
|
|
|
296
|
+
## The trollbox, and saying a line out loud
|
|
297
|
+
|
|
298
|
+
Every live room has a trollbox: the chat for whoever has joined that stream,
|
|
299
|
+
kept at nixamp.com and keyed by the server and the channel, so everybody
|
|
300
|
+
watching one stream is in the same box whichever page they came from.
|
|
301
|
+
Reading it needs nobody. A line needs a nixamp.com sign-in, and is signed
|
|
302
|
+
with the account's public handle, never its address.
|
|
303
|
+
|
|
304
|
+
A line can be said rather than typed. The microphone button beside the box
|
|
305
|
+
is tap, talk, tap: the page records, brings the sound to 16 kHz mono itself,
|
|
306
|
+
and sends nixamp.com a small WAV; the words come back into the box, and
|
|
307
|
+
**Send** is still yours, so a misheard word is fixed before the room sees it.
|
|
308
|
+
The ear is [Whisper](https://github.com/openai/whisper) run through
|
|
309
|
+
[Transformers.js](https://github.com/huggingface/transformers.js), an
|
|
310
|
+
Apache-2.0 library carrying MIT-licensed models, on nixamp.com's own CPU.
|
|
311
|
+
Nothing is sent to a speech vendor and nothing is billed. It works in the
|
|
312
|
+
PWA, the desktop app and on a phone, wherever the browser can record; the
|
|
313
|
+
button only appears where a line can be sent from, which is signed in on
|
|
314
|
+
nixamp.com.
|
|
315
|
+
|
|
316
|
+
The same ear is one route, for anything else that has a recording:
|
|
317
|
+
|
|
318
|
+
```
|
|
319
|
+
POST /api/v1/speech/transcribe a WAV in (16-bit PCM; 16 kHz mono is ideal), {text} out
|
|
320
|
+
POST /api/v1/speech/transcribe?server=URL&channel=ID and the words posted to that room
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Signed in only, up to a minute at a time, twelve asks a minute per account,
|
|
324
|
+
`?language=de` when Whisper should not guess. The CLI and the MCP server
|
|
325
|
+
front the same route:
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
nixamp transcribe clip.m4a the words in a recording
|
|
329
|
+
nixamp transcribe clip.m4a --say https://server1.chovy.nixamp.com:4321
|
|
330
|
+
nixamp transcribe clip.m4a --say URL --channel cat-1
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Anything ffmpeg can read is converted here first; a WAV needs no ffmpeg.
|
|
334
|
+
`nixamp mcp` offers `transcribe_audio` (with the same optional room),
|
|
335
|
+
`trollbox_say` and `trollbox_read`.
|
|
336
|
+
|
|
337
|
+
### Subtitles: what a live is saying
|
|
338
|
+
|
|
339
|
+
Every live channel can be captioned. The server carrying it listens to its
|
|
340
|
+
own stream, turns the sound into five-second windows with its ffmpeg, and
|
|
341
|
+
has nixamp.com's ear turn each window into a line stamped with the moment
|
|
342
|
+
its sound was at the live edge. The lines go out as Server-Sent Events:
|
|
343
|
+
|
|
344
|
+
```
|
|
345
|
+
GET /api/channels/ID/captions an event stream: `hello` with the recent lines, then a `line` each
|
|
346
|
+
GET /api/channels/ID/transcript the recent lines as JSON (?after=MS for only the new ones)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Both are read with the same key as the sound. The page opens the stream as
|
|
350
|
+
soon as you join a live and shows a **Transcript** panel, on by default:
|
|
351
|
+
each line is held until your own playback has reached the sound it came
|
|
352
|
+
from (the backlog you were handed, plus a little buffering) and then shown,
|
|
353
|
+
on the picture when there is one and in the panel always. Close to the
|
|
354
|
+
voice, not on it: a line is a window, not a word. The switch in the panel
|
|
355
|
+
turns captions off for that device; the Panels list hides the panel.
|
|
356
|
+
|
|
357
|
+
A captioner runs only while somebody is asking, and stops a minute after
|
|
358
|
+
the last one leaves; silence between songs is never sent. The server needs
|
|
359
|
+
an ffmpeg and a sign-in (`nixamp login`) for the ear to answer it. In the
|
|
360
|
+
terminal, `nixamp transcript --channel ID --follow` prints the lines as
|
|
361
|
+
they come; an agent reads them with the `transcript_read` tool.
|
|
362
|
+
|
|
363
|
+
The model is an optional dependency, because it is hundreds of megabytes
|
|
364
|
+
with the ONNX runtime under it and the CLI tarball is pure JavaScript. A
|
|
365
|
+
`nixamp serve` on a laptop answers 503 to this route and every client asks
|
|
366
|
+
nixamp.com instead. `NIXAMP_STT_MODEL` picks another Whisper
|
|
367
|
+
(`onnx-community/whisper-base` by default; `whisper-small` hears better and
|
|
368
|
+
takes twice as long), `NIXAMP_STT_CACHE` says where its files are kept, and
|
|
369
|
+
`NIXAMP_STT=off` leaves the ear out of a deployment altogether.
|
|
370
|
+
|
|
296
371
|
## Several streams at once
|
|
297
372
|
|
|
298
373
|
A channel is one publisher and everybody listening to them. Two or three devices
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Listener } from "./channels.ts";
|
|
2
|
+
export interface CaptionLine {
|
|
3
|
+
/** The channel's id. */
|
|
4
|
+
channel: string;
|
|
5
|
+
/** When the sound this line is from began and ended, wall clock, ms. */
|
|
6
|
+
at: number;
|
|
7
|
+
until: number;
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
/** What turns a channel's bytes into 16 kHz mono 16-bit PCM. ffmpeg, or a test's stand-in. */
|
|
11
|
+
export interface Decoder {
|
|
12
|
+
write(chunk: Buffer): boolean;
|
|
13
|
+
end(): void;
|
|
14
|
+
}
|
|
15
|
+
export interface CaptionsOptions {
|
|
16
|
+
/** A listener on a channel, or null when there is no such channel. */
|
|
17
|
+
listen: (id: string, listener: Listener) => (() => void) | null;
|
|
18
|
+
ffmpeg: string[];
|
|
19
|
+
/** Whose ear to use: this server's own sign-in, read when a captioner starts. Null means no captions. */
|
|
20
|
+
session: () => {
|
|
21
|
+
site: string;
|
|
22
|
+
token: string;
|
|
23
|
+
} | null;
|
|
24
|
+
fetcher?: typeof fetch;
|
|
25
|
+
/** How bytes become PCM. The default spawns ffmpeg; the tests hand in something quieter. */
|
|
26
|
+
decoder?: (onPcm: (pcm: Buffer) => void, onEnd: () => void) => Decoder;
|
|
27
|
+
now?: () => number;
|
|
28
|
+
onEvent?: (message: string) => void;
|
|
29
|
+
windowMs?: number;
|
|
30
|
+
idleMs?: number;
|
|
31
|
+
}
|
|
32
|
+
export declare const WINDOW_MS = 5000;
|
|
33
|
+
/** Lines kept per channel for whoever arrives late. */
|
|
34
|
+
export declare const KEEP = 200;
|
|
35
|
+
export declare const IDLE_MS = 60000;
|
|
36
|
+
/** Below this RMS (about -48 dBFS) a window is silence, and never sent. */
|
|
37
|
+
export declare const QUIET = 0.004;
|
|
38
|
+
/** Windows waiting on the ear at once. Past this the sound is dropped, not queued: late words are worse than none. */
|
|
39
|
+
export declare const IN_FLIGHT = 2;
|
|
40
|
+
/**
|
|
41
|
+
* The ffmpeg arguments: whatever arrives on stdin, as PCM on stdout, with
|
|
42
|
+
* a short probe so the first line is not long in coming. Never `-fflags
|
|
43
|
+
* nobuffer`: it drops the packets it probed, which took the first 1.7
|
|
44
|
+
* seconds out of every captioner and began every transcript mid-sentence.
|
|
45
|
+
*/
|
|
46
|
+
export declare function decoderArgs(): string[];
|
|
47
|
+
/** Whether a window of 16-bit PCM has anything in it worth hearing. */
|
|
48
|
+
export declare function isQuiet(pcm: Buffer, threshold?: number): boolean;
|
|
49
|
+
/** A WAV around 16-bit mono PCM, without copying it through floats. */
|
|
50
|
+
export declare function wavAround(pcm: Buffer, rate?: number): Buffer;
|
|
51
|
+
type Subscriber = (line: CaptionLine) => void;
|
|
52
|
+
export declare class Captions {
|
|
53
|
+
private readonly options;
|
|
54
|
+
private readonly running;
|
|
55
|
+
constructor(options: CaptionsOptions);
|
|
56
|
+
/** Whether this server can caption at all: it has to be signed in for the ear to answer it. */
|
|
57
|
+
available(): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Lines for a channel as they are heard, starting the captioner if it is
|
|
60
|
+
* not running. Null when there is no such channel. The returned function
|
|
61
|
+
* is how to stop listening; the captioner itself stops a minute after the
|
|
62
|
+
* last listener does.
|
|
63
|
+
*/
|
|
64
|
+
subscribe(id: string, subscriber: Subscriber): (() => void) | null;
|
|
65
|
+
/** The recent lines of a channel, oldest first, after a moment when given. Empty when nobody has asked for them. */
|
|
66
|
+
recent(id: string, after?: number): CaptionLine[];
|
|
67
|
+
/** Whether a channel is being captioned, and what last went wrong if the lines are not coming. */
|
|
68
|
+
status(id: string): {
|
|
69
|
+
on: boolean;
|
|
70
|
+
lines: number;
|
|
71
|
+
error: string;
|
|
72
|
+
};
|
|
73
|
+
stopAll(): void;
|
|
74
|
+
}
|
|
75
|
+
export {};
|
package/dist/captions.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Captions: what a live channel is saying, written down as it says it.
|
|
3
|
+
*
|
|
4
|
+
* One captioner per channel, started when the first person asks for the
|
|
5
|
+
* transcript and stopped a minute after the last one leaves, because it
|
|
6
|
+
* costs a CPU somewhere for as long as it runs. It listens to the channel
|
|
7
|
+
* exactly as a browser does -- the same bytes, from the same backlog --
|
|
8
|
+
* hands them to an ffmpeg that turns them into 16 kHz mono PCM, cuts that
|
|
9
|
+
* into five-second windows, and sends each window to nixamp.com's ear
|
|
10
|
+
* (see speech.ts) signed in as this server. The words come back as a
|
|
11
|
+
* line, stamped with the wall-clock moment the sound was heard, so a page
|
|
12
|
+
* can hold each line until its own playback gets there and the subtitles
|
|
13
|
+
* land close to the voice.
|
|
14
|
+
*
|
|
15
|
+
* Five seconds is the trade. Shorter windows hear less context and cost
|
|
16
|
+
* more asks; longer ones make the words later than the sound. A line is a
|
|
17
|
+
* window: no word-level timing, because the ear takes twice as long when
|
|
18
|
+
* asked for it and the page is only ever close to the voice, not on it.
|
|
19
|
+
*
|
|
20
|
+
* A quiet window -- the gap between songs, a picture with no talking -- is
|
|
21
|
+
* never sent. Most of a music channel is that, and hearing it costs the
|
|
22
|
+
* same as hearing speech.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { RATE } from "./speech.js";
|
|
26
|
+
export const WINDOW_MS = 5000;
|
|
27
|
+
/** Lines kept per channel for whoever arrives late. */
|
|
28
|
+
export const KEEP = 200;
|
|
29
|
+
export const IDLE_MS = 60_000;
|
|
30
|
+
/** Below this RMS (about -48 dBFS) a window is silence, and never sent. */
|
|
31
|
+
export const QUIET = 0.004;
|
|
32
|
+
/** Windows waiting on the ear at once. Past this the sound is dropped, not queued: late words are worse than none. */
|
|
33
|
+
export const IN_FLIGHT = 2;
|
|
34
|
+
/**
|
|
35
|
+
* The ffmpeg arguments: whatever arrives on stdin, as PCM on stdout, with
|
|
36
|
+
* a short probe so the first line is not long in coming. Never `-fflags
|
|
37
|
+
* nobuffer`: it drops the packets it probed, which took the first 1.7
|
|
38
|
+
* seconds out of every captioner and began every transcript mid-sentence.
|
|
39
|
+
*/
|
|
40
|
+
export function decoderArgs() {
|
|
41
|
+
return [
|
|
42
|
+
"-v", "error", "-nostats",
|
|
43
|
+
"-flags", "low_delay",
|
|
44
|
+
"-analyzeduration", "500000", "-probesize", "262144",
|
|
45
|
+
"-i", "pipe:0",
|
|
46
|
+
"-vn", "-ac", "1", "-ar", String(RATE), "-f", "s16le", "pipe:1",
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
function ffmpegDecoder(ffmpeg, onPcm, onEnd) {
|
|
50
|
+
const [command, ...prefix] = ffmpeg;
|
|
51
|
+
const child = spawn(command, [...prefix, ...decoderArgs()], { stdio: ["pipe", "pipe", "ignore"] });
|
|
52
|
+
let ended = false;
|
|
53
|
+
const end = () => {
|
|
54
|
+
if (ended)
|
|
55
|
+
return;
|
|
56
|
+
ended = true;
|
|
57
|
+
onEnd();
|
|
58
|
+
};
|
|
59
|
+
child.stdin.on("error", () => undefined);
|
|
60
|
+
child.stdout.on("data", (chunk) => onPcm(chunk));
|
|
61
|
+
child.on("error", end);
|
|
62
|
+
child.on("close", end);
|
|
63
|
+
return {
|
|
64
|
+
write: (chunk) => {
|
|
65
|
+
if (ended || child.stdin.destroyed)
|
|
66
|
+
return false;
|
|
67
|
+
try {
|
|
68
|
+
child.stdin.write(chunk);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
},
|
|
75
|
+
end: () => {
|
|
76
|
+
try {
|
|
77
|
+
child.stdin.end();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Already gone.
|
|
81
|
+
}
|
|
82
|
+
if (!ended) {
|
|
83
|
+
const kill = setTimeout(() => child.kill("SIGKILL"), 2000);
|
|
84
|
+
kill.unref?.();
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Whether a window of 16-bit PCM has anything in it worth hearing. */
|
|
90
|
+
export function isQuiet(pcm, threshold = QUIET) {
|
|
91
|
+
const samples = Math.floor(pcm.length / 2);
|
|
92
|
+
if (samples === 0)
|
|
93
|
+
return true;
|
|
94
|
+
let sum = 0;
|
|
95
|
+
for (let i = 0; i < samples; i++) {
|
|
96
|
+
const value = pcm.readInt16LE(i * 2) / 32768;
|
|
97
|
+
sum += value * value;
|
|
98
|
+
}
|
|
99
|
+
return Math.sqrt(sum / samples) < threshold;
|
|
100
|
+
}
|
|
101
|
+
/** A WAV around 16-bit mono PCM, without copying it through floats. */
|
|
102
|
+
export function wavAround(pcm, rate = RATE) {
|
|
103
|
+
const header = Buffer.alloc(44);
|
|
104
|
+
header.write("RIFF", 0, "ascii");
|
|
105
|
+
header.writeUInt32LE(36 + pcm.length, 4);
|
|
106
|
+
header.write("WAVE", 8, "ascii");
|
|
107
|
+
header.write("fmt ", 12, "ascii");
|
|
108
|
+
header.writeUInt32LE(16, 16);
|
|
109
|
+
header.writeUInt16LE(1, 20);
|
|
110
|
+
header.writeUInt16LE(1, 22);
|
|
111
|
+
header.writeUInt32LE(rate, 24);
|
|
112
|
+
header.writeUInt32LE(rate * 2, 28);
|
|
113
|
+
header.writeUInt16LE(2, 32);
|
|
114
|
+
header.writeUInt16LE(16, 34);
|
|
115
|
+
header.write("data", 36, "ascii");
|
|
116
|
+
header.writeUInt32LE(pcm.length, 40);
|
|
117
|
+
return Buffer.concat([header, pcm]);
|
|
118
|
+
}
|
|
119
|
+
class Captioner {
|
|
120
|
+
id;
|
|
121
|
+
options;
|
|
122
|
+
onStop;
|
|
123
|
+
lines = [];
|
|
124
|
+
subscribers = new Set();
|
|
125
|
+
/** The last thing that went wrong, for whoever asks why there are no lines. */
|
|
126
|
+
error = "";
|
|
127
|
+
decoder = null;
|
|
128
|
+
detach = null;
|
|
129
|
+
idle = null;
|
|
130
|
+
pending = [];
|
|
131
|
+
pendingBytes = 0;
|
|
132
|
+
inFlight = 0;
|
|
133
|
+
stopped = false;
|
|
134
|
+
complainedAt = 0;
|
|
135
|
+
constructor(id, options, onStop) {
|
|
136
|
+
this.id = id;
|
|
137
|
+
this.options = options;
|
|
138
|
+
this.onStop = onStop;
|
|
139
|
+
}
|
|
140
|
+
get windowBytes() {
|
|
141
|
+
return Math.round(((this.options.windowMs ?? WINDOW_MS) / 1000) * RATE) * 2;
|
|
142
|
+
}
|
|
143
|
+
start() {
|
|
144
|
+
const make = this.options.decoder ?? ((onPcm, onEnd) => ffmpegDecoder(this.options.ffmpeg, onPcm, onEnd));
|
|
145
|
+
this.decoder = make((pcm) => this.onPcm(pcm), () => this.stop());
|
|
146
|
+
this.detach = this.options.listen(this.id, {
|
|
147
|
+
write: (chunk) => this.decoder?.write(chunk) ?? false,
|
|
148
|
+
end: () => this.stop(),
|
|
149
|
+
});
|
|
150
|
+
if (this.detach === null) {
|
|
151
|
+
this.stop();
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
onPcm(pcm) {
|
|
157
|
+
if (this.stopped)
|
|
158
|
+
return;
|
|
159
|
+
this.pending.push(pcm);
|
|
160
|
+
this.pendingBytes += pcm.length;
|
|
161
|
+
const size = this.windowBytes;
|
|
162
|
+
while (this.pendingBytes >= size) {
|
|
163
|
+
const all = Buffer.concat(this.pending);
|
|
164
|
+
const window = all.subarray(0, size);
|
|
165
|
+
const rest = all.subarray(size);
|
|
166
|
+
this.pending = rest.length > 0 ? [Buffer.from(rest)] : [];
|
|
167
|
+
this.pendingBytes = rest.length;
|
|
168
|
+
const until = (this.options.now ?? Date.now)();
|
|
169
|
+
void this.hear(Buffer.from(window), until - (this.options.windowMs ?? WINDOW_MS), until);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async hear(pcm, at, until) {
|
|
173
|
+
if (isQuiet(pcm))
|
|
174
|
+
return;
|
|
175
|
+
if (this.inFlight >= IN_FLIGHT)
|
|
176
|
+
return;
|
|
177
|
+
const session = this.options.session();
|
|
178
|
+
if (session === null) {
|
|
179
|
+
this.complain("this server is not signed in, so it cannot caption; `nixamp login` on it");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
this.inFlight += 1;
|
|
183
|
+
try {
|
|
184
|
+
const wav = wavAround(pcm);
|
|
185
|
+
const response = await (this.options.fetcher ?? fetch)(`${session.site.replace(/\/+$/, "")}/api/v1/speech/transcribe`, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: { authorization: `Bearer ${session.token}`, "content-type": "audio/wav" },
|
|
188
|
+
body: new Blob([wav.buffer.slice(wav.byteOffset, wav.byteOffset + wav.byteLength)]),
|
|
189
|
+
});
|
|
190
|
+
const body = (await response.json().catch(() => ({})));
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
this.complain(body.error ?? `nixamp.com answered ${response.status}`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const text = (body.text ?? "").trim();
|
|
196
|
+
if (text === "" || this.stopped)
|
|
197
|
+
return;
|
|
198
|
+
this.error = "";
|
|
199
|
+
const line = { channel: this.id, at, until, text };
|
|
200
|
+
this.lines.push(line);
|
|
201
|
+
while (this.lines.length > KEEP)
|
|
202
|
+
this.lines.shift();
|
|
203
|
+
for (const subscriber of this.subscribers) {
|
|
204
|
+
try {
|
|
205
|
+
subscriber(line);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// A listener that throws is not this channel's problem.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
this.complain(`could not reach the ear: ${error.message}`);
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
this.inFlight -= 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** Said once a minute at most: a broken ear would otherwise say so twelve times a minute. */
|
|
220
|
+
complain(message) {
|
|
221
|
+
this.error = message;
|
|
222
|
+
const now = (this.options.now ?? Date.now)();
|
|
223
|
+
if (now - this.complainedAt < 60_000)
|
|
224
|
+
return;
|
|
225
|
+
this.complainedAt = now;
|
|
226
|
+
this.options.onEvent?.(`captions for "${this.id}": ${message}`);
|
|
227
|
+
}
|
|
228
|
+
subscribe(subscriber) {
|
|
229
|
+
this.subscribers.add(subscriber);
|
|
230
|
+
if (this.idle)
|
|
231
|
+
clearTimeout(this.idle);
|
|
232
|
+
this.idle = null;
|
|
233
|
+
return () => {
|
|
234
|
+
this.subscribers.delete(subscriber);
|
|
235
|
+
if (this.subscribers.size === 0 && !this.stopped) {
|
|
236
|
+
this.idle = setTimeout(() => {
|
|
237
|
+
if (this.subscribers.size === 0)
|
|
238
|
+
this.stop();
|
|
239
|
+
}, this.options.idleMs ?? IDLE_MS);
|
|
240
|
+
this.idle.unref?.();
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
stop() {
|
|
245
|
+
if (this.stopped)
|
|
246
|
+
return;
|
|
247
|
+
this.stopped = true;
|
|
248
|
+
if (this.idle)
|
|
249
|
+
clearTimeout(this.idle);
|
|
250
|
+
this.idle = null;
|
|
251
|
+
this.detach?.();
|
|
252
|
+
this.detach = null;
|
|
253
|
+
this.decoder?.end();
|
|
254
|
+
this.decoder = null;
|
|
255
|
+
this.pending = [];
|
|
256
|
+
this.pendingBytes = 0;
|
|
257
|
+
this.subscribers.clear();
|
|
258
|
+
this.onStop();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
export class Captions {
|
|
262
|
+
options;
|
|
263
|
+
running = new Map();
|
|
264
|
+
constructor(options) {
|
|
265
|
+
this.options = options;
|
|
266
|
+
}
|
|
267
|
+
/** Whether this server can caption at all: it has to be signed in for the ear to answer it. */
|
|
268
|
+
available() {
|
|
269
|
+
return this.options.session() !== null;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Lines for a channel as they are heard, starting the captioner if it is
|
|
273
|
+
* not running. Null when there is no such channel. The returned function
|
|
274
|
+
* is how to stop listening; the captioner itself stops a minute after the
|
|
275
|
+
* last listener does.
|
|
276
|
+
*/
|
|
277
|
+
subscribe(id, subscriber) {
|
|
278
|
+
let captioner = this.running.get(id);
|
|
279
|
+
if (!captioner) {
|
|
280
|
+
const made = new Captioner(id, this.options, () => {
|
|
281
|
+
if (this.running.get(id) === made)
|
|
282
|
+
this.running.delete(id);
|
|
283
|
+
});
|
|
284
|
+
this.running.set(id, made);
|
|
285
|
+
if (!made.start())
|
|
286
|
+
return null;
|
|
287
|
+
this.options.onEvent?.(`captions for "${id}": started`);
|
|
288
|
+
captioner = made;
|
|
289
|
+
}
|
|
290
|
+
return captioner.subscribe(subscriber);
|
|
291
|
+
}
|
|
292
|
+
/** The recent lines of a channel, oldest first, after a moment when given. Empty when nobody has asked for them. */
|
|
293
|
+
recent(id, after = 0) {
|
|
294
|
+
const captioner = this.running.get(id);
|
|
295
|
+
if (!captioner)
|
|
296
|
+
return [];
|
|
297
|
+
return after > 0 ? captioner.lines.filter((line) => line.at > after) : [...captioner.lines];
|
|
298
|
+
}
|
|
299
|
+
/** Whether a channel is being captioned, and what last went wrong if the lines are not coming. */
|
|
300
|
+
status(id) {
|
|
301
|
+
const captioner = this.running.get(id);
|
|
302
|
+
return captioner ? { on: true, lines: captioner.lines.length, error: captioner.error } : { on: false, lines: 0, error: "" };
|
|
303
|
+
}
|
|
304
|
+
stopAll() {
|
|
305
|
+
for (const captioner of [...this.running.values()])
|
|
306
|
+
captioner.stop();
|
|
307
|
+
}
|
|
308
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -59,6 +59,8 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
59
59
|
nixamp server list|add|remove the machines you run, kept against your account
|
|
60
60
|
nixamp party list|join|host watch parties, here and on the sites nixamp is connected to
|
|
61
61
|
nixamp mcp speak Model Context Protocol on stdin, for an agent
|
|
62
|
+
nixamp transcribe FILE [--say SERVER] the words in a recording, and into a trollbox
|
|
63
|
+
nixamp transcript --channel ID [--follow] what a channel is saying, as it says it
|
|
62
64
|
nixamp opendir list|add|remove folders found on the web, published for everyone
|
|
63
65
|
nixamp update [version] re-run the installer, keeping your choices
|
|
64
66
|
nixamp uninstall [--yes] remove everything the installer created
|
|
@@ -236,10 +238,43 @@ takes it away again.
|
|
|
236
238
|
nixamp mcp speak Model Context Protocol on stdin and stdout
|
|
237
239
|
|
|
238
240
|
It offers the watch party tools: list them, read one, put one on the air,
|
|
239
|
-
say where playback is, end it.
|
|
240
|
-
|
|
241
|
+
say where playback is, end it. And the room tools: transcribe a recording
|
|
242
|
+
(transcribe_audio, which can post the words straight into a trollbox), say a
|
|
243
|
+
line in a room (trollbox_say), read a room (trollbox_read), read what a
|
|
244
|
+
channel is saying (transcript_read). It acts as
|
|
245
|
+
whoever this machine is signed in as, so \`nixamp login\` (or NIXAMP_TOKEN)
|
|
246
|
+
comes first.
|
|
241
247
|
|
|
242
248
|
Point an MCP client at it as a stdio server running \`nixamp mcp\`.
|
|
249
|
+
`,
|
|
250
|
+
transcribe: `nixamp transcribe — say it, and have it written down.
|
|
251
|
+
|
|
252
|
+
nixamp transcribe FILE the words in a recording
|
|
253
|
+
nixamp transcribe FILE --say SERVER and post them to that server's trollbox
|
|
254
|
+
nixamp transcribe FILE --say SERVER --channel ID to one channel's room (default: live)
|
|
255
|
+
nixamp transcribe FILE --language de when Whisper should not guess
|
|
256
|
+
nixamp transcribe FILE --json the answer as JSON
|
|
257
|
+
|
|
258
|
+
FILE is any recording ffmpeg can read; a WAV needs no ffmpeg at all. The
|
|
259
|
+
hearing is done by nixamp.com with an open-source model (Whisper, through
|
|
260
|
+
Transformers.js) on its own CPU: nothing goes to a speech vendor. It needs a
|
|
261
|
+
sign-in (\`nixamp login\`) and nothing else. Up to a minute at a time.
|
|
262
|
+
|
|
263
|
+
The same ear is behind the microphone button in every nixamp.com trollbox,
|
|
264
|
+
and behind the transcribe_audio tool of \`nixamp mcp\`.
|
|
265
|
+
`,
|
|
266
|
+
transcript: `nixamp transcript — what a channel is saying, written down.
|
|
267
|
+
|
|
268
|
+
nixamp transcript --channel ID the recent lines from this machine's daemon
|
|
269
|
+
nixamp transcript --url URL --key K --channel ID from another server, with its share link
|
|
270
|
+
nixamp transcript ... --follow and keep printing as it speaks
|
|
271
|
+
nixamp transcript ... --json the lines as JSON
|
|
272
|
+
|
|
273
|
+
A server captions a channel while somebody is asking for its transcript: its
|
|
274
|
+
own ffmpeg turns the sound into five-second windows, nixamp.com's ear turns
|
|
275
|
+
those into lines, each stamped with when its sound was heard. The page shows
|
|
276
|
+
them as subtitles, held until its own sound gets there; this prints them.
|
|
277
|
+
The server needs an ffmpeg and a sign-in (\`nixamp login\`).
|
|
243
278
|
`,
|
|
244
279
|
attach: `nixamp attach — the player, in front of the running daemon.
|
|
245
280
|
|
|
@@ -437,6 +472,16 @@ export async function main() {
|
|
|
437
472
|
process.exitCode = await party(rest);
|
|
438
473
|
return;
|
|
439
474
|
}
|
|
475
|
+
if (first === "transcript" || first === "captions") {
|
|
476
|
+
const { transcript } = await import("./transcript.js");
|
|
477
|
+
process.exitCode = await transcript(rest);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (first === "transcribe" || first === "dictate") {
|
|
481
|
+
const { transcribe } = await import("./transcribe.js");
|
|
482
|
+
process.exitCode = await transcribe(rest);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
440
485
|
if (first === "mcp") {
|
|
441
486
|
const { mcp } = await import("./mcp.js");
|
|
442
487
|
process.exitCode = await mcp();
|
package/dist/mcp.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { wavOf } from "./transcribe.ts";
|
|
1
2
|
export declare const PROTOCOL_VERSION = "2025-06-18";
|
|
2
3
|
interface Request {
|
|
3
4
|
jsonrpc: "2.0";
|
|
@@ -18,6 +19,8 @@ export interface McpOptions {
|
|
|
18
19
|
site: string;
|
|
19
20
|
token: string;
|
|
20
21
|
} | null;
|
|
22
|
+
/** How a recording becomes a WAV; the tests hand in a fake. */
|
|
23
|
+
wavOf?: typeof wavOf;
|
|
21
24
|
say?: (line: string) => void;
|
|
22
25
|
}
|
|
23
26
|
/** A tool answer, in the shape MCP wants: content blocks, and a flag for failure. */
|