nixamp 0.21.5 → 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 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 {};
@@ -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. It acts as whoever this machine is signed in
240
- as, so \`nixamp login\` (or NIXAMP_TOKEN) comes first.
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. */