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/src/server.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  } from "./broadcast.ts";
28
28
  import { Ingest, normaliseFormat } from "./ingest.ts";
29
29
  import {
30
- Channels, cleanId, generatedId, rememberChannels, rememberedChannels, rememberedNow,
30
+ BACKLOG_SECONDS, Channels, cleanId, generatedId, rememberChannels, rememberedChannels, rememberedNow,
31
31
  type Channel, type ChannelInfo, type RememberedChannel,
32
32
  } from "./channels.ts";
33
33
  import { RtmpListeners } from "./rtmp-in.ts";
@@ -84,6 +84,8 @@ import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.ts";
84
84
  import { Layouts } from "./layouts.ts";
85
85
  import { Rooms } from "./rooms.ts";
86
86
  import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.ts";
87
+ import { MAX_BYTES as SPEECH_BYTES, Speech, SpeechError, isWav, languageOf } from "./speech.ts";
88
+ import { Captions } from "./captions.ts";
87
89
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
88
90
  import {
89
91
  applyRemoteConfig,
@@ -1430,6 +1432,19 @@ async function readBody(request: IncomingMessage, limit = 64 * 1024): Promise<st
1430
1432
  return Buffer.concat(chunks).toString("utf8");
1431
1433
  }
1432
1434
 
1435
+ /** The body as bytes: sound, not text. */
1436
+ async function readBytes(request: IncomingMessage, limit: number): Promise<Uint8Array> {
1437
+ const chunks: Buffer[] = [];
1438
+ let size = 0;
1439
+ for await (const chunk of request) {
1440
+ const buffer = chunk as Buffer;
1441
+ size += buffer.length;
1442
+ if (size > limit) throw new SpeechError("that is too much sound", 413);
1443
+ chunks.push(buffer);
1444
+ }
1445
+ return new Uint8Array(Buffer.concat(chunks));
1446
+ }
1447
+
1433
1448
  export interface HandlerOptions {
1434
1449
  web: string | null;
1435
1450
  media: boolean;
@@ -1456,6 +1471,8 @@ export interface HandlerOptions {
1456
1471
  ytdlp?: string[] | null;
1457
1472
  /** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */
1458
1473
  hls?: HlsPackagers;
1474
+ /** Captions for a channel: its sound, as lines, as they are heard. Needs ffmpeg and a sign-in. */
1475
+ captions?: Captions;
1459
1476
  /** Lossless relay compression, its policies, diagnostics and static representations. */
1460
1477
  compression?: CompressionService;
1461
1478
  /** What a name is -- a film, a channel, a fixture -- asked of nichedb.dev and remembered. */
@@ -1584,6 +1601,8 @@ export interface HandlerOptions {
1584
1601
  rooms?: Rooms;
1585
1602
  /** The trollbox: a chat per live room, kept at nixamp.com. */
1586
1603
  trollbox?: Trollbox;
1604
+ /** Speech to text: a line said out loud, heard here. Needs the optional model. */
1605
+ speech?: Speech;
1587
1606
  /** Tickets: a paid pass to one event's room. Absent means every show is free. */
1588
1607
  tickets?: Tickets;
1589
1608
  /**
@@ -2611,6 +2630,54 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2611
2630
  return;
2612
2631
  }
2613
2632
 
2633
+ /*
2634
+ * Speech to text: a WAV in, the words out. Signed in only -- the ear
2635
+ * costs CPU and a trollbox line needs a name anyway -- and, given a
2636
+ * room, the words go straight into that room's trollbox as a line by
2637
+ * whoever spoke them. The page, the CLI and the MCP tools all come here.
2638
+ */
2639
+ if (path === "/api/v1/speech/transcribe" && options.speech && options.accounts) {
2640
+ const speech = options.speech;
2641
+ try {
2642
+ if (request.method !== "POST") {
2643
+ json(response, 405, { error: "POST a WAV" });
2644
+ return;
2645
+ }
2646
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
2647
+ if (who === null) {
2648
+ json(response, 401, { error: "sign in to nixamp.com to dictate" });
2649
+ return;
2650
+ }
2651
+ const bytes = await readBytes(request, SPEECH_BYTES);
2652
+ if (!isWav(bytes)) {
2653
+ json(response, 415, { error: "send a WAV: 16-bit PCM, mono, 16 kHz is ideal" });
2654
+ return;
2655
+ }
2656
+ // A room named is a line posted, by the same rules as typing it.
2657
+ const roomAsked = url.searchParams.has("server") || url.searchParams.has("channel");
2658
+ const where = roomAsked ? roomFor(url.searchParams.get("server"), url.searchParams.get("channel")) : null;
2659
+ if (roomAsked && !where) {
2660
+ json(response, 400, { error: "a room is a server address and a channel" });
2661
+ return;
2662
+ }
2663
+ const heard = await speech.transcribe(bytes, { language: languageOf(url.searchParams.get("language")), by: who.id });
2664
+ if (where && options.trollbox && heard.text !== "") {
2665
+ const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
2666
+ const line = await options.trollbox.post(where, who.id, handle, heard.text);
2667
+ json(response, 201, {
2668
+ text: heard.text, seconds: heard.seconds, model: speech.model,
2669
+ message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true },
2670
+ });
2671
+ return;
2672
+ }
2673
+ json(response, 200, { text: heard.text, seconds: heard.seconds, model: speech.model });
2674
+ } catch (error) {
2675
+ if (error instanceof SpeechError || error instanceof TrollboxError) json(response, error.status, { error: error.message });
2676
+ else throw error;
2677
+ }
2678
+ return;
2679
+ }
2680
+
2614
2681
  if (path === "/api/v1/me/handle" && options.handles && options.accounts) {
2615
2682
  const handles = options.handles;
2616
2683
  const who = await options.accounts.whoIs(tokenFrom(request.headers));
@@ -3707,6 +3774,68 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3707
3774
  return;
3708
3775
  }
3709
3776
 
3777
+ /*
3778
+ * The channel's captions: what it is saying, as lines, as they are
3779
+ * heard by nixamp.com's ear on this server's behalf. Read the way the
3780
+ * sound is read -- the key gate above has already passed -- and
3781
+ * started by the first person asking. `captions` is the live stream
3782
+ * of lines; `transcript` is the recent ones as JSON, for a poll.
3783
+ */
3784
+ if ((action === "captions" || action === "transcript") && request.method === "GET") {
3785
+ const captions = options.captions;
3786
+ if (!captions) {
3787
+ json(response, 503, { error: "this server cannot caption: it has no ffmpeg" });
3788
+ return;
3789
+ }
3790
+ if (!captions.available()) {
3791
+ json(response, 503, { error: "this server is not signed in to nixamp.com, so it cannot caption; run `nixamp login` on it" });
3792
+ return;
3793
+ }
3794
+ if (!channels.has(id)) {
3795
+ json(response, 404, { error: "nothing is playing on that channel" });
3796
+ return;
3797
+ }
3798
+ // Lines after a moment: a poll's ?after=, or the last line a
3799
+ // reconnecting EventSource saw, which the browser sends by itself.
3800
+ const lastSeen = request.headers["last-event-id"];
3801
+ const after = Number(url.searchParams.get("after") ?? (Array.isArray(lastSeen) ? lastSeen[0] : lastSeen) ?? 0) || 0;
3802
+ if (action === "transcript") {
3803
+ // Asking keeps the captioner up: it stops a minute after the last ask.
3804
+ captions.subscribe(id, () => undefined)?.();
3805
+ json(response, 200, {
3806
+ channel: id, backlog: BACKLOG_SECONDS, now: Date.now(), ...captions.status(id), lines: captions.recent(id, after),
3807
+ });
3808
+ return;
3809
+ }
3810
+ response.writeHead(200, {
3811
+ ...CORS,
3812
+ "content-type": "text/event-stream; charset=utf-8",
3813
+ "cache-control": "no-store",
3814
+ connection: "keep-alive",
3815
+ "x-accel-buffering": "no",
3816
+ });
3817
+ const write = (event: string, data: unknown, eventId?: number): void => {
3818
+ response.write(`${eventId === undefined ? "" : `id: ${eventId}\n`}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
3819
+ };
3820
+ const off = captions.subscribe(id, (line) => write("line", line, line.at));
3821
+ if (off === null) {
3822
+ response.end();
3823
+ return;
3824
+ }
3825
+ // How far behind the live edge a newcomer's playback starts, so the
3826
+ // page can hold each line until its own sound gets there.
3827
+ write("hello", { channel: id, backlog: BACKLOG_SECONDS, now: Date.now(), ...captions.status(id), lines: captions.recent(id, after) });
3828
+ const beat = setInterval(() => response.write(": beat\n\n"), 20_000);
3829
+ beat.unref?.();
3830
+ const done = (): void => {
3831
+ clearInterval(beat);
3832
+ off();
3833
+ };
3834
+ request.on("close", done);
3835
+ response.on("close", done);
3836
+ return;
3837
+ }
3838
+
3710
3839
  if (action === undefined && request.method === "GET") {
3711
3840
  // Listening. The response is the fan-out target: whatever ffmpeg
3712
3841
  // produces for this channel is written to it until one end goes away.
@@ -5051,6 +5180,15 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5051
5180
  secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
5052
5181
  })
5053
5182
  : undefined;
5183
+ // The ear lives where the accounts live. Loaded now, in the background, so
5184
+ // the first person to speak does not wait for the model to arrive; a box
5185
+ // without the optional model never says it is ready, and answers 503.
5186
+ const speech = accounts && process.env["NIXAMP_STT"] !== "off" ? new Speech() : undefined;
5187
+ if (speech) {
5188
+ void speech.warm().then((ready) => {
5189
+ if (ready) console.error(`nixamp: hearing with ${speech.model}`);
5190
+ });
5191
+ }
5054
5192
 
5055
5193
  // nixamp as an OAuth 2.1 authorization server, and the watch parties a
5056
5194
  // client site bridges through it. Both need the same three things -- a
@@ -5235,6 +5373,18 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5235
5373
  }
5236
5374
 
5237
5375
  const session = readSession();
5376
+ // Captions for a channel: this server's ffmpeg turns the sound into PCM
5377
+ // and nixamp.com's ear, asked as this server, turns that into lines. A
5378
+ // server without ffmpeg cannot; a server nobody signed in on is refused
5379
+ // by the ear, and says so to whoever asks.
5380
+ const captions = tools.carries
5381
+ ? new Captions({
5382
+ ffmpeg: tools.ffmpeg,
5383
+ listen: (id, listener) => channels.listen(id, listener),
5384
+ session: () => readSession(),
5385
+ onEvent: (message) => console.log(` ${message}`),
5386
+ })
5387
+ : undefined;
5238
5388
  const owner = new Owner({
5239
5389
  ownerId: options.owner || (session?.token ? await ownerIdOf(session) : ""),
5240
5390
  site: session?.site ?? DEFAULT_DIRECTORY,
@@ -5426,6 +5576,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5426
5576
  carries: tools.carries !== false,
5427
5577
  cookies: cookiesFile(),
5428
5578
  hls,
5579
+ ...(captions ? { captions } : {}),
5429
5580
  compression,
5430
5581
  enricher,
5431
5582
  ...(tls ? { tls } : {}),
@@ -5440,6 +5591,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5440
5591
  ...(layouts ? { layouts } : {}),
5441
5592
  ...(rooms ? { rooms } : {}),
5442
5593
  ...(trollbox ? { trollbox } : {}),
5594
+ ...(speech ? { speech } : {}),
5443
5595
  ...(tickets ? { tickets } : {}),
5444
5596
  ...(authServer ? { authServer } : {}),
5445
5597
  ...(parties ? { parties } : {}),
package/src/speech.ts ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Speech to text: a line said into a microphone, heard by nixamp.com.
3
+ *
4
+ * The ear is Whisper, run through Transformers.js: an Apache-2.0 library
5
+ * carrying MIT-licensed models, on this machine's own CPU. Nothing leaves
6
+ * for a speech vendor, and nothing is billed. The web page, the CLI and the
7
+ * MCP tools all send the same thing -- a short WAV -- to the same route,
8
+ * and get words back; the route can also drop those words straight into a
9
+ * trollbox, which is what dictating a line to a room means.
10
+ *
11
+ * The library is an optional dependency on purpose. It is hundreds of
12
+ * megabytes with the ONNX runtime under it, native per platform, and the
13
+ * CLI tarball's promise is "pure JavaScript, runs anywhere a Node does".
14
+ * So a `nixamp serve` on a laptop answers 503 to this, and every client
15
+ * asks nixamp.com instead -- which is where a trollbox line has to be
16
+ * signed in anyway.
17
+ *
18
+ * A WAV is decoded here rather than by ffmpeg because nixamp.com has no
19
+ * ffmpeg (see the Dockerfile). Anything else is converted before it is
20
+ * sent: the browser resamples what it recorded, the CLI runs ffmpeg.
21
+ */
22
+ import { join } from "node:path";
23
+ import { stateDir } from "./daemon.ts";
24
+
25
+ /** What Whisper listens at. Everything is brought to this before it is heard. */
26
+ export const RATE = 16_000;
27
+ /** A trollbox line, said out loud, is seconds long; a minute is the ceiling. */
28
+ export const MAX_SECONDS = 60;
29
+ /** A minute of 16-bit mono at 16 kHz is under 2 MB; 48 kHz stereo is under 12. */
30
+ export const MAX_BYTES = 12 * 1024 * 1024;
31
+ /**
32
+ * How much one account may have heard in a minute, in seconds of sound.
33
+ * Counted in sound rather than asks because a live channel being captioned
34
+ * asks twelve times a minute for five seconds each, and a person dictating
35
+ * asks twice for thirty: the cost is the sound, not the call. Five minutes
36
+ * of sound a minute is four channels captioned, or a conversation, and
37
+ * not a firehose.
38
+ */
39
+ export const SECONDS_PER_MINUTE = 300;
40
+ /** How many may wait for the one CPU. Past this, the honest answer is "later". */
41
+ export const QUEUE_LIMIT = 8;
42
+ export const DEFAULT_MODEL = "onnx-community/whisper-base";
43
+
44
+ export class SpeechError extends Error {
45
+ constructor(message: string, readonly status: number) {
46
+ super(message);
47
+ }
48
+ }
49
+
50
+ export interface Heard {
51
+ text: string;
52
+ /** How long the audio was, in seconds. */
53
+ seconds: number;
54
+ }
55
+
56
+ /** Mono samples in [-1, 1] at RATE -> words. What the loaded model is, to this file. */
57
+ export type Recognizer = (pcm: Float32Array, options: { language?: string }) => Promise<{ text: string }>;
58
+
59
+ export interface SpeechOptions {
60
+ /** A Hugging Face model id; NIXAMP_STT_MODEL otherwise; whisper-base by default. */
61
+ model?: string;
62
+ /** Where the model's files are kept between runs. */
63
+ cacheDir?: string;
64
+ /** How the model is loaded. The tests hand in a fake; nothing else does. */
65
+ load?: (model: string, cacheDir: string) => Promise<Recognizer>;
66
+ now?: () => number;
67
+ }
68
+
69
+ export interface Wav {
70
+ rate: number;
71
+ channels: number;
72
+ /** Mixed down to one channel, in [-1, 1]. */
73
+ samples: Float32Array;
74
+ }
75
+
76
+ /** Whether these bytes are a RIFF/WAVE file, whatever the request called them. */
77
+ export function isWav(bytes: Uint8Array): boolean {
78
+ if (bytes.length < 12) return false;
79
+ const ascii = (at: number, length: number): string => String.fromCharCode(...bytes.subarray(at, at + length));
80
+ return ascii(0, 4) === "RIFF" && ascii(8, 4) === "WAVE";
81
+ }
82
+
83
+ /**
84
+ * A WAV file's samples, mixed to mono. PCM of 8, 16, 24 or 32 bits, or
85
+ * 32-bit float; the WAVE_FORMAT_EXTENSIBLE wrapper around either. That is
86
+ * what every encoder that matters writes, including the one in the page.
87
+ */
88
+ export function decodeWav(bytes: Uint8Array): Wav {
89
+ if (!isWav(bytes)) throw new SpeechError("send a WAV file", 415);
90
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
91
+ let at = 12;
92
+ let format = 0;
93
+ let channels = 0;
94
+ let rate = 0;
95
+ let bits = 0;
96
+ let dataAt = -1;
97
+ let dataLength = 0;
98
+ while (at + 8 <= bytes.length) {
99
+ const id = String.fromCharCode(...bytes.subarray(at, at + 4));
100
+ const length = view.getUint32(at + 4, true);
101
+ const body = at + 8;
102
+ if (id === "fmt " && length >= 16) {
103
+ format = view.getUint16(body, true);
104
+ channels = view.getUint16(body + 2, true);
105
+ rate = view.getUint32(body + 4, true);
106
+ bits = view.getUint16(body + 14, true);
107
+ // Extensible: the real format is the first two bytes of the sub-format GUID.
108
+ if (format === 0xfffe && length >= 26) format = view.getUint16(body + 24, true);
109
+ } else if (id === "data") {
110
+ dataAt = body;
111
+ // A streamed WAV says 0 or 0xFFFFFFFF for a length it did not know yet.
112
+ dataLength = length === 0 || body + length > bytes.length ? bytes.length - body : length;
113
+ break;
114
+ }
115
+ at = body + length + (length & 1);
116
+ }
117
+ if (dataAt < 0 || channels < 1 || rate < 8000 || rate > 192_000) throw new SpeechError("that WAV has no sound in it", 415);
118
+ const pcm = format === 1 && (bits === 8 || bits === 16 || bits === 24 || bits === 32);
119
+ const float = format === 3 && bits === 32;
120
+ if (!pcm && !float) throw new SpeechError(`WAV format ${format} at ${bits} bits is not one this reads: send 16-bit PCM`, 415);
121
+ const width = bits / 8;
122
+ const frames = Math.floor(dataLength / (width * channels));
123
+ const samples = new Float32Array(frames);
124
+ for (let frame = 0; frame < frames; frame++) {
125
+ let sum = 0;
126
+ for (let channel = 0; channel < channels; channel++) {
127
+ const offset = dataAt + (frame * channels + channel) * width;
128
+ if (float) sum += view.getFloat32(offset, true);
129
+ else if (bits === 8) sum += (bytes[offset] as number) / 128 - 1;
130
+ else if (bits === 16) sum += view.getInt16(offset, true) / 32768;
131
+ else if (bits === 24) sum += (((bytes[offset + 2] as number) << 24) | ((bytes[offset + 1] as number) << 16) | ((bytes[offset] as number) << 8)) / 2147483648;
132
+ else sum += view.getInt32(offset, true) / 2147483648;
133
+ }
134
+ samples[frame] = sum / channels;
135
+ }
136
+ return { rate, channels, samples };
137
+ }
138
+
139
+ /**
140
+ * Samples at one rate, at another. Down is an average over each output
141
+ * sample's span, which is a crude low-pass and enough for speech; up is a
142
+ * straight line between neighbours. Whisper resamples nothing itself.
143
+ */
144
+ export function resample(samples: Float32Array, from: number, to: number): Float32Array {
145
+ if (from === to || samples.length === 0) return samples;
146
+ const ratio = from / to;
147
+ const length = Math.max(1, Math.round(samples.length / ratio));
148
+ const out = new Float32Array(length);
149
+ if (ratio > 1) {
150
+ for (let i = 0; i < length; i++) {
151
+ const start = Math.floor(i * ratio);
152
+ const end = Math.min(samples.length, Math.max(start + 1, Math.floor((i + 1) * ratio)));
153
+ let sum = 0;
154
+ for (let j = start; j < end; j++) sum += samples[j] as number;
155
+ out[i] = sum / (end - start);
156
+ }
157
+ } else {
158
+ for (let i = 0; i < length; i++) {
159
+ const position = i * ratio;
160
+ const left = Math.floor(position);
161
+ const right = Math.min(samples.length - 1, left + 1);
162
+ const mix = position - left;
163
+ out[i] = (samples[left] as number) * (1 - mix) + (samples[right] as number) * mix;
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /** 16-bit mono PCM WAV bytes from samples: what the CLI's tests, and anybody, can send. */
170
+ export function encodeWav(samples: Float32Array, rate = RATE): Uint8Array {
171
+ const bytes = new Uint8Array(44 + samples.length * 2);
172
+ const view = new DataView(bytes.buffer);
173
+ const ascii = (at: number, text: string): void => {
174
+ for (let i = 0; i < text.length; i++) bytes[at + i] = text.charCodeAt(i);
175
+ };
176
+ ascii(0, "RIFF");
177
+ view.setUint32(4, 36 + samples.length * 2, true);
178
+ ascii(8, "WAVE");
179
+ ascii(12, "fmt ");
180
+ view.setUint32(16, 16, true);
181
+ view.setUint16(20, 1, true);
182
+ view.setUint16(22, 1, true);
183
+ view.setUint32(24, rate, true);
184
+ view.setUint32(28, rate * 2, true);
185
+ view.setUint16(32, 2, true);
186
+ view.setUint16(34, 16, true);
187
+ ascii(36, "data");
188
+ view.setUint32(40, samples.length * 2, true);
189
+ for (let i = 0; i < samples.length; i++) {
190
+ const clipped = Math.max(-1, Math.min(1, samples[i] as number));
191
+ view.setInt16(44 + i * 2, clipped < 0 ? clipped * 32768 : clipped * 32767, true);
192
+ }
193
+ return bytes;
194
+ }
195
+
196
+ /** Whisper's own spacing and blank-audio tokens, tidied into a line. */
197
+ export function tidy(text: string): string {
198
+ return text.replace(/\[[A-Z_ ]+\]|\([A-Za-z ]+\)/g, " ").replace(/\s+/g, " ").trim();
199
+ }
200
+
201
+ /** A two-letter language code, or nothing: Whisper guesses when not told. */
202
+ export function languageOf(value: unknown): string | undefined {
203
+ if (typeof value !== "string") return undefined;
204
+ const code = value.trim().toLowerCase();
205
+ return /^[a-z]{2}$/.test(code) ? code : undefined;
206
+ }
207
+
208
+ /** The module, as much of it as this file touches. Typed here so the import can be by name. */
209
+ interface Transformers {
210
+ env: { cacheDir?: string; allowLocalModels?: boolean };
211
+ pipeline(task: "automatic-speech-recognition", model: string, options: { dtype: string }): Promise<
212
+ (audio: Float32Array, options: Record<string, unknown>) => Promise<{ text: string } | { text: string }[]>
213
+ >;
214
+ }
215
+
216
+ async function loadWhisper(model: string, cacheDir: string): Promise<Recognizer> {
217
+ // By name held in a variable: an optional dependency that is not on disk
218
+ // must fail here, at the first ask, and not when the file is imported.
219
+ const name = "@huggingface/transformers";
220
+ let transformers: Transformers;
221
+ try {
222
+ transformers = (await import(name)) as Transformers;
223
+ } catch {
224
+ throw new SpeechError("this nixamp cannot hear: @huggingface/transformers is not installed here. nixamp.com can.", 503);
225
+ }
226
+ transformers.env.cacheDir = cacheDir;
227
+ const recognize = await transformers.pipeline("automatic-speech-recognition", model, { dtype: "q8" });
228
+ return async (pcm, { language }) => {
229
+ const heard = await recognize(pcm, {
230
+ // Whisper hears thirty seconds at a time; longer is heard in overlapping pieces.
231
+ chunk_length_s: 30,
232
+ stride_length_s: 5,
233
+ ...(language ? { language, task: "transcribe" } : {}),
234
+ });
235
+ return { text: Array.isArray(heard) ? heard.map((piece) => piece.text).join(" ") : heard.text };
236
+ };
237
+ }
238
+
239
+ export class Speech {
240
+ readonly model: string;
241
+ private readonly cacheDir: string;
242
+ private readonly load: (model: string, cacheDir: string) => Promise<Recognizer>;
243
+ private readonly now: () => number;
244
+ private recognizer: Promise<Recognizer> | null = null;
245
+ /** One at a time: the model is CPU-bound, and two at once is slower than two in turn. */
246
+ private tail: Promise<unknown> = Promise.resolve();
247
+ private waiting = 0;
248
+ private readonly asked = new Map<string, { minute: number; seconds: number }>();
249
+
250
+ constructor(options: SpeechOptions = {}) {
251
+ this.model = options.model ?? process.env["NIXAMP_STT_MODEL"] ?? DEFAULT_MODEL;
252
+ this.cacheDir = options.cacheDir ?? process.env["NIXAMP_STT_CACHE"] ?? join(stateDir(), "models");
253
+ this.load = options.load ?? loadWhisper;
254
+ this.now = options.now ?? (() => Date.now());
255
+ }
256
+
257
+ private ear(): Promise<Recognizer> {
258
+ this.recognizer ??= this.load(this.model, this.cacheDir).catch((error: unknown) => {
259
+ // A failed load is tried again next time, not remembered forever.
260
+ this.recognizer = null;
261
+ throw error;
262
+ });
263
+ return this.recognizer;
264
+ }
265
+
266
+ /**
267
+ * Load the model now, so the first person to speak is not the one who
268
+ * waits for the download. Says whether it could; never throws.
269
+ */
270
+ async warm(): Promise<boolean> {
271
+ try {
272
+ await this.ear();
273
+ return true;
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+
279
+ /** Whether this account may have this much heard now, and the bookkeeping if so. */
280
+ allow(accountId: string, seconds: number): void {
281
+ const minute = Math.floor(this.now() / 60_000);
282
+ const record = this.asked.get(accountId) ?? { minute, seconds: 0 };
283
+ if (record.minute !== minute) {
284
+ record.minute = minute;
285
+ record.seconds = 0;
286
+ }
287
+ if (record.seconds + seconds > SECONDS_PER_MINUTE) {
288
+ throw new SpeechError(`${SECONDS_PER_MINUTE} seconds of sound a minute is plenty; try again in a moment`, 429);
289
+ }
290
+ record.seconds += seconds;
291
+ this.asked.set(accountId, record);
292
+ if (this.asked.size > 5000) {
293
+ for (const [id, one] of this.asked) if (one.minute !== minute) this.asked.delete(id);
294
+ }
295
+ }
296
+
297
+ /**
298
+ * The words in a WAV. Refuses what is not a WAV, or is too long, or more
299
+ * than the account may have heard this minute, with a status.
300
+ */
301
+ async transcribe(bytes: Uint8Array, options: { language?: string; by?: string } = {}): Promise<Heard> {
302
+ if (bytes.length > MAX_BYTES) throw new SpeechError(`that is too much sound: ${MAX_SECONDS} seconds at most`, 413);
303
+ const wav = decodeWav(bytes);
304
+ const seconds = wav.samples.length / wav.rate;
305
+ if (seconds > MAX_SECONDS) throw new SpeechError(`that is ${Math.round(seconds)} seconds; ${MAX_SECONDS} at most`, 413);
306
+ if (options.by) this.allow(options.by, seconds);
307
+ if (wav.samples.length < wav.rate / 10) return { text: "", seconds };
308
+ const pcm = resample(wav.samples, wav.rate, RATE);
309
+ if (this.waiting >= QUEUE_LIMIT) throw new SpeechError("too many people are talking at once; try again in a moment", 503);
310
+ this.waiting += 1;
311
+ const turn = this.tail.then(async () => {
312
+ const recognize = await this.ear();
313
+ return recognize(pcm, options.language ? { language: options.language } : {});
314
+ });
315
+ // The queue moves on whether or not this one was heard.
316
+ this.tail = turn.catch(() => undefined);
317
+ try {
318
+ const heard = await turn;
319
+ return { text: tidy(heard.text), seconds };
320
+ } finally {
321
+ this.waiting -= 1;
322
+ }
323
+ }
324
+ }