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/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
|
+
}
|