poddrill 0.0.0-stage → 0.1.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/dist/disk.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Filesystem error codes and JSON files, read at the disk boundary.
3
+ */
4
+ import { readFile, stat } from "node:fs/promises";
5
+ export function errorCode(error) {
6
+ if (error instanceof Error && "code" in error && typeof error.code === "string") {
7
+ return error.code;
8
+ }
9
+ return undefined;
10
+ }
11
+ export async function pathExists(path) {
12
+ try {
13
+ await stat(path);
14
+ return true;
15
+ }
16
+ catch (error) {
17
+ if (errorCode(error) === "ENOENT") {
18
+ return false;
19
+ }
20
+ throw error;
21
+ }
22
+ }
23
+ /** `undefined` when the file is missing or is not JSON. */
24
+ export async function readJsonFile(path) {
25
+ let text;
26
+ try {
27
+ text = await readFile(path, "utf8");
28
+ }
29
+ catch (error) {
30
+ if (errorCode(error) === "ENOENT") {
31
+ return undefined;
32
+ }
33
+ throw error;
34
+ }
35
+ try {
36
+ return JSON.parse(text);
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ export function asRecord(value) {
43
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
44
+ return undefined;
45
+ }
46
+ return Object.fromEntries(Object.entries(value));
47
+ }
package/dist/embed.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The local ECAPA embedding host. One python3 process per fleet slot, never shared with Whistle.
3
+ * Ready frame: { "kind": "ready" }. Result: 192 numbers for one stretch wav.
4
+ * Frontend: float samples times 32768, Kaldi fbank with 80 bins, hamming, 25 ms window, 10 ms shift,
5
+ * dither 0, then CMN, then the ONNX session on CPU.
6
+ */
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { pathExists } from "./disk.js";
10
+ import { openFleet } from "./fleet.js";
11
+ import { PYTHON_FRAMES, spawnFramedWorker } from "./worker.js";
12
+ export const EMBEDDING_SIZE = 192;
13
+ /** `PODDRILL_EMBED_WEIGHTS`, else `~/.cache/poddrill/ecapa/voxceleb_ECAPA512.onnx`. */
14
+ export function embedWeightsPath(env) {
15
+ const configured = env["PODDRILL_EMBED_WEIGHTS"];
16
+ if (configured !== undefined && configured !== "") {
17
+ return configured;
18
+ }
19
+ return join(homedir(), ".cache", "poddrill", "ecapa", "voxceleb_ECAPA512.onnx");
20
+ }
21
+ /** Spawns nothing until the first stretch. A missing weights file fails each call without spawning. */
22
+ export function openEmbedHost(embeddingLimit, weightsPath) {
23
+ if (!Number.isInteger(embeddingLimit) || embeddingLimit < 1) {
24
+ throw new Error(`embedding limit must be a positive integer, got ${embeddingLimit}`);
25
+ }
26
+ const pool = openFleet({
27
+ limit: embeddingLimit,
28
+ spawn: spawnEmbedWorker(weightsPath),
29
+ closedMessage: "embedding host is closed",
30
+ });
31
+ const requireWeights = async () => {
32
+ if (!(await pathExists(weightsPath))) {
33
+ throw new Error(`ECAPA weights are missing at ${weightsPath}. Put the file there or set PODDRILL_EMBED_WEIGHTS.`);
34
+ }
35
+ };
36
+ return {
37
+ prepare: requireWeights,
38
+ async embedStretch(wavPath) {
39
+ await requireWeights();
40
+ return pool.run(wavPath);
41
+ },
42
+ close: () => pool.close(),
43
+ release: () => pool.release(),
44
+ };
45
+ }
46
+ function spawnEmbedWorker(weightsPath) {
47
+ return (events) => {
48
+ const link = spawnFramedWorker({
49
+ label: "ecapa",
50
+ args: ["-c", WORKER_SCRIPT, weightsPath],
51
+ checkReady: async () => undefined,
52
+ parseResult: parseEmbedding,
53
+ }, events);
54
+ return {
55
+ request: (wavPath) => link.request({ wavPath }),
56
+ close: () => link.close(),
57
+ };
58
+ };
59
+ }
60
+ function parseEmbedding(value) {
61
+ if (!Array.isArray(value)) {
62
+ throw new Error("ecapa result must be an array");
63
+ }
64
+ const vector = [];
65
+ for (const item of value) {
66
+ if (typeof item !== "number" || !Number.isFinite(item)) {
67
+ throw new Error("ecapa result must hold finite numbers");
68
+ }
69
+ vector.push(item);
70
+ }
71
+ if (vector.length !== EMBEDDING_SIZE) {
72
+ throw new Error(`ecapa result has ${vector.length} values, not ${EMBEDDING_SIZE}`);
73
+ }
74
+ return vector;
75
+ }
76
+ /** Mirrors torchaudio's `compliance.kaldi.fbank`, including DC removal and 0.97 preemphasis. */
77
+ const WORKER_SCRIPT = [
78
+ PYTHON_FRAMES,
79
+ "import wave",
80
+ "import numpy as np",
81
+ "import onnxruntime as ort",
82
+ "session = ort.InferenceSession(sys.argv[1], providers=[\"CPUExecutionProvider\"])",
83
+ "feed = session.get_inputs()[0].name",
84
+ "RATE = 16000",
85
+ "FRAME = 400",
86
+ "SHIFT = 160",
87
+ "FFT = 512",
88
+ "BINS = 80",
89
+ "EPS = float(np.finfo(np.float32).eps)",
90
+ "def mel(freq):",
91
+ " return 1127.0 * np.log(1.0 + freq / 700.0)",
92
+ "def mel_banks():",
93
+ " low = mel(20.0)",
94
+ " delta = (mel(RATE / 2.0) - low) / (BINS + 1)",
95
+ " bins = mel(np.arange(FFT // 2) * (RATE / FFT))[None, :]",
96
+ " left = low + np.arange(BINS)[:, None] * delta",
97
+ " center = left + delta",
98
+ " right = center + delta",
99
+ " banks = np.maximum(0.0, np.minimum((bins - left) / delta, (right - bins) / delta))",
100
+ " return np.pad(banks, ((0, 0), (0, 1)))",
101
+ "BANKS = mel_banks()",
102
+ "WINDOW = 0.54 - 0.46 * np.cos(2.0 * np.pi * np.arange(FRAME) / (FRAME - 1))",
103
+ "def fbank(samples):",
104
+ " if len(samples) < FRAME:",
105
+ " raise ValueError(\"stretch is shorter than one 25 ms frame\")",
106
+ " count = 1 + (len(samples) - FRAME) // SHIFT",
107
+ " index = np.arange(FRAME)[None, :] + SHIFT * np.arange(count)[:, None]",
108
+ " frames = samples[index].astype(np.float64)",
109
+ " frames = frames - frames.mean(axis=1, keepdims=True)",
110
+ " frames = frames - 0.97 * np.concatenate([frames[:, :1], frames[:, :-1]], axis=1)",
111
+ " power = np.abs(np.fft.rfft(frames * WINDOW, n=FFT)) ** 2",
112
+ " return np.log(np.maximum(power @ BANKS.T, EPS))",
113
+ "def read_wav(path):",
114
+ " with wave.open(path, \"rb\") as reader:",
115
+ " if reader.getnchannels() != 1 or reader.getsampwidth() != 2 or reader.getframerate() != RATE:",
116
+ " raise ValueError(path + \" is not 16-bit mono 16 kHz wav\")",
117
+ " pcm = reader.readframes(reader.getnframes())",
118
+ " return np.frombuffer(pcm, dtype=\"<i2\").astype(np.float64) / 32768.0",
119
+ "def embed(message):",
120
+ " wav_path = message[\"wavPath\"]",
121
+ " feats = fbank(read_wav(wav_path) * 32768.0)",
122
+ " feats = feats - feats.mean(axis=0, keepdims=True)",
123
+ " output = session.run(None, {feed: feats[None, :, :].astype(np.float32)})[0]",
124
+ " return [float(value) for value in np.asarray(output).reshape(-1)]",
125
+ "send({\"kind\": \"ready\"})",
126
+ "serve(embed)",
127
+ ].join("\n");
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Episode clock, slice plan, and WebVTT projection.
3
+ * Cue times are built only by adding a slice start to a Whistle word time.
4
+ */
5
+ /** Milliseconds. Constructed only by `shift`. */
6
+ export class EpisodeTime {
7
+ milliseconds;
8
+ constructor(milliseconds) {
9
+ this.milliseconds = milliseconds;
10
+ }
11
+ /**
12
+ * The only way to build an episode time.
13
+ * `sliceStartMs` is the slice offset. `wordSeconds` is Whistle's slice-relative clock.
14
+ */
15
+ static shift(sliceStartMs, wordSeconds) {
16
+ if (!Number.isInteger(sliceStartMs) || sliceStartMs < 0) {
17
+ throw new Error("slice start must be a non-negative integer millisecond");
18
+ }
19
+ if (!Number.isFinite(wordSeconds) || wordSeconds < 0) {
20
+ throw new Error("word time must be a non-negative finite second");
21
+ }
22
+ return new EpisodeTime(sliceStartMs + Math.round(wordSeconds * 1000));
23
+ }
24
+ }
25
+ /** 28 seconds, under Whistle's 30 second limit. */
26
+ export const SLICE_MS = 28000;
27
+ /** Overlap so a hard cut can drop the repeated word from the later slice. */
28
+ export const OVERLAP_MS = 1000;
29
+ /** A cue longer than this closes even when Whistle omitted punctuation. */
30
+ export const MAX_CUE_MS = 12000;
31
+ const STEP_MS = SLICE_MS - OVERLAP_MS;
32
+ /** Fixed grid. No silence detector. */
33
+ export function planSlices(durationMs) {
34
+ if (!Number.isInteger(durationMs) || durationMs < 0) {
35
+ throw new Error("duration must be a non-negative integer millisecond");
36
+ }
37
+ if (durationMs === 0) {
38
+ return [];
39
+ }
40
+ const slices = [];
41
+ let index = 0;
42
+ for (let start = 0; start < durationMs; start += STEP_MS) {
43
+ const end = Math.min(start + SLICE_MS, durationMs);
44
+ const keepEnd = end === durationMs ? durationMs : start + STEP_MS;
45
+ slices.push({
46
+ index,
47
+ startMs: start,
48
+ endMs: end,
49
+ keepStartMs: start,
50
+ keepEndMs: keepEnd,
51
+ });
52
+ index += 1;
53
+ if (end === durationMs) {
54
+ break;
55
+ }
56
+ }
57
+ return slices;
58
+ }
59
+ /**
60
+ * Rebuild cues and WebVTT from slice records.
61
+ * A rerun with the same records returns the same text.
62
+ */
63
+ export function projectSlices(records) {
64
+ const kept = [];
65
+ const segments = [];
66
+ for (const record of records) {
67
+ const fates = [];
68
+ if (record.whistle.kind === "speech") {
69
+ record.whistle.words.forEach((word, wordIndex) => {
70
+ const start = EpisodeTime.shift(record.plan.startMs, word.start);
71
+ let end = EpisodeTime.shift(record.plan.startMs, word.end);
72
+ if (end.milliseconds <= start.milliseconds) {
73
+ end = EpisodeTime.shift(record.plan.startMs, word.start + 0.001);
74
+ }
75
+ const inWindow = start.milliseconds >= record.plan.keepStartMs &&
76
+ start.milliseconds < record.plan.keepEndMs;
77
+ if (inWindow) {
78
+ kept.push({
79
+ sliceIndex: record.plan.index,
80
+ wordIndex,
81
+ text: word.word,
82
+ start,
83
+ end,
84
+ });
85
+ }
86
+ fates.push({
87
+ wordIndex,
88
+ word: word.word,
89
+ episodeStart: start,
90
+ episodeEnd: end,
91
+ kept: inWindow,
92
+ cueId: undefined,
93
+ });
94
+ });
95
+ }
96
+ segments.push({
97
+ sliceIndex: record.plan.index,
98
+ slicePath: record.slicePath,
99
+ startMs: record.plan.startMs,
100
+ endMs: record.plan.endMs,
101
+ whistle: record.whistle,
102
+ words: fates,
103
+ });
104
+ }
105
+ const cues = groupCues(kept);
106
+ const withCueIds = assignCueIds(segments, cues);
107
+ return {
108
+ vtt: renderVtt(cues),
109
+ cues,
110
+ segments: withCueIds,
111
+ };
112
+ }
113
+ function groupCues(words) {
114
+ const cues = [];
115
+ let bucket = [];
116
+ const close = () => {
117
+ if (bucket.length === 0) {
118
+ return;
119
+ }
120
+ const first = bucket[0];
121
+ const last = bucket[bucket.length - 1];
122
+ if (first === undefined || last === undefined) {
123
+ return;
124
+ }
125
+ cues.push({
126
+ id: cueId(cues.length),
127
+ start: first.start,
128
+ end: last.end,
129
+ text: bucket.map((word) => word.text).join(" "),
130
+ pieces: bucket.map((word) => ({
131
+ sliceIndex: word.sliceIndex,
132
+ wordIndex: word.wordIndex,
133
+ })),
134
+ });
135
+ bucket = [];
136
+ };
137
+ for (const word of words) {
138
+ const first = bucket[0];
139
+ if (first !== undefined &&
140
+ word.start.milliseconds - first.start.milliseconds >= MAX_CUE_MS) {
141
+ close();
142
+ }
143
+ bucket.push(word);
144
+ // CJK lines end on 。!?. The ASCII class never sees them, so the cue would run on.
145
+ if (/[.!?。!?]$/.test(word.text)) {
146
+ close();
147
+ }
148
+ }
149
+ close();
150
+ return cues;
151
+ }
152
+ function cueId(index) {
153
+ return `c${String(index + 1).padStart(4, "0")}`;
154
+ }
155
+ function assignCueIds(segments, cues) {
156
+ const cueByPiece = new Map();
157
+ for (const cue of cues) {
158
+ for (const piece of cue.pieces) {
159
+ cueByPiece.set(`${piece.sliceIndex}:${piece.wordIndex}`, cue.id);
160
+ }
161
+ }
162
+ return segments.map((segment) => ({
163
+ ...segment,
164
+ words: segment.words.map((word) => ({
165
+ ...word,
166
+ cueId: cueByPiece.get(`${segment.sliceIndex}:${word.wordIndex}`),
167
+ })),
168
+ }));
169
+ }
170
+ function renderVtt(cues) {
171
+ const blocks = cues.map((cue) => {
172
+ return [
173
+ cue.id,
174
+ `${stamp(cue.start.milliseconds)} --> ${stamp(cue.end.milliseconds)}`,
175
+ escapeCue(cue.text),
176
+ ].join("\n");
177
+ });
178
+ return ["WEBVTT", ...blocks].join("\n\n").concat("\n");
179
+ }
180
+ function stamp(ms) {
181
+ const hours = Math.floor(ms / 3_600_000);
182
+ const minutes = Math.floor((ms % 3_600_000) / 60_000);
183
+ const seconds = Math.floor((ms % 60_000) / 1_000);
184
+ const millis = ms % 1_000;
185
+ return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${String(millis).padStart(3, "0")}`;
186
+ }
187
+ function pad(value) {
188
+ return String(value).padStart(2, "0");
189
+ }
190
+ function escapeCue(text) {
191
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
192
+ }
193
+ function asRecord(value) {
194
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
195
+ return undefined;
196
+ }
197
+ return Object.fromEntries(Object.entries(value));
198
+ }
199
+ function readString(record, key) {
200
+ const value = record[key];
201
+ if (typeof value !== "string") {
202
+ throw new Error(`Whistle field ${key} must be a string`);
203
+ }
204
+ return value;
205
+ }
206
+ function readNumber(record, key) {
207
+ const value = record[key];
208
+ if (typeof value !== "number" || !Number.isFinite(value)) {
209
+ throw new Error(`Whistle field ${key} must be a finite number`);
210
+ }
211
+ return value;
212
+ }
213
+ /** Parse one Whistle JSON object at the boundary. */
214
+ export function parseWhistle(value) {
215
+ const record = asRecord(value);
216
+ if (record === undefined) {
217
+ throw new Error("Whistle result must be an object");
218
+ }
219
+ const text = readString(record, "text");
220
+ const language = readString(record, "language");
221
+ const ttftMs = readNumber(record, "ttft_ms");
222
+ const decodeTps = readNumber(record, "decode_tps");
223
+ const wordsValue = record["words"];
224
+ if (text === "" && language === "" && !Array.isArray(wordsValue)) {
225
+ return { kind: "silence", text: "", language: "", ttftMs, decodeTps };
226
+ }
227
+ if (!Array.isArray(wordsValue)) {
228
+ throw new Error("Whistle speech result must include words");
229
+ }
230
+ const words = wordsValue.map((item) => {
231
+ const word = asRecord(item);
232
+ if (word === undefined) {
233
+ throw new Error("Whistle word must be an object");
234
+ }
235
+ return {
236
+ word: readString(word, "word"),
237
+ start: readNumber(word, "start"),
238
+ end: readNumber(word, "end"),
239
+ probability: readNumber(word, "probability"),
240
+ };
241
+ });
242
+ return { kind: "speech", text, language, ttftMs, decodeTps, words };
243
+ }
244
+ /** Inverse of `parseWhistle`. Writes Whistle's wire field names. */
245
+ export function serializeWhistle(result) {
246
+ const wire = {
247
+ text: result.text,
248
+ language: result.language,
249
+ ttft_ms: result.ttftMs,
250
+ decode_tps: result.decodeTps,
251
+ };
252
+ if (result.kind === "silence") {
253
+ return JSON.stringify(wire);
254
+ }
255
+ return JSON.stringify({
256
+ ...wire,
257
+ words: result.words.map(({ word, start, end, probability }) => ({ word, start, end, probability })),
258
+ });
259
+ }
package/dist/fleet.js ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * A warm, demand-grown fleet of single-request workers behind one `run`.
3
+ * Policy (high-water hold):
4
+ * - Nothing spawns at open.
5
+ * - Grow by one when a request is waiting and every live worker is busy, while live < limit.
6
+ * A booting worker is live and not busy, so at most one worker boots at a time.
7
+ * - Idle workers stay until `release` or `close`.
8
+ * - A dead worker is removed. It is replaced only by the grow rule above.
9
+ * Dead is represented by absence from `slots`, so the live count is `slots.size`.
10
+ */
11
+ /** Hosts pass their process spawner. Tests pass in-memory links. */
12
+ export function openFleet(options) {
13
+ return new Fleet(options.limit, options.spawn, options.closedMessage);
14
+ }
15
+ /** Insertion order of `slots` is spawn order, so dispatch prefers the oldest idle worker. */
16
+ function nextMove(slots, waiting, limit) {
17
+ if (waiting === 0) {
18
+ return { kind: "hold" };
19
+ }
20
+ let booting = false;
21
+ for (const [workerId, slot] of slots) {
22
+ switch (slot.state) {
23
+ case "idle":
24
+ return { kind: "dispatch", workerId, link: slot.link };
25
+ case "booting":
26
+ booting = true;
27
+ break;
28
+ case "busy":
29
+ break;
30
+ default: {
31
+ const leftover = slot.state;
32
+ throw new Error(`unexpected worker state ${String(leftover)}`);
33
+ }
34
+ }
35
+ }
36
+ if (booting || slots.size >= limit) {
37
+ return { kind: "hold" };
38
+ }
39
+ return { kind: "grow" };
40
+ }
41
+ class Fleet {
42
+ limit;
43
+ spawn;
44
+ closedMessage;
45
+ /** Live workers only: booting, idle, or busy. */
46
+ slots = new Map();
47
+ /** FIFO. With one lane upstream this keeps today's request order. */
48
+ waiting = [];
49
+ nextId = 0;
50
+ closed = false;
51
+ constructor(limit, spawn, closedMessage) {
52
+ this.limit = limit;
53
+ this.spawn = spawn;
54
+ this.closedMessage = closedMessage;
55
+ }
56
+ run(query) {
57
+ if (this.closed) {
58
+ return Promise.reject(new Error(this.closedMessage));
59
+ }
60
+ return new Promise((resolve, reject) => {
61
+ this.waiting.push({ query, resolve, reject });
62
+ this.pump();
63
+ });
64
+ }
65
+ async close() {
66
+ if (this.closed) {
67
+ return;
68
+ }
69
+ this.closed = true;
70
+ for (const waiter of this.waiting.splice(0)) {
71
+ waiter.reject(new Error(this.closedMessage));
72
+ }
73
+ for (const slot of this.slots.values()) {
74
+ slot.link.close();
75
+ }
76
+ this.slots.clear();
77
+ }
78
+ release() {
79
+ if (this.closed || this.waiting.length > 0) {
80
+ return false;
81
+ }
82
+ for (const slot of this.slots.values()) {
83
+ switch (slot.state) {
84
+ case "idle":
85
+ break;
86
+ case "booting":
87
+ case "busy":
88
+ return false;
89
+ default: {
90
+ const leftover = slot.state;
91
+ throw new Error(`unexpected worker state ${String(leftover)}`);
92
+ }
93
+ }
94
+ }
95
+ for (const slot of this.slots.values()) {
96
+ slot.link.close();
97
+ }
98
+ this.slots.clear();
99
+ return true;
100
+ }
101
+ pump() {
102
+ while (!this.closed) {
103
+ const move = nextMove(this.slots, this.waiting.length, this.limit);
104
+ switch (move.kind) {
105
+ case "hold":
106
+ return;
107
+ case "grow": {
108
+ const id = this.nextId;
109
+ this.nextId += 1;
110
+ const link = this.spawn({
111
+ ready: () => {
112
+ this.onReady(id);
113
+ },
114
+ exit: (reason) => {
115
+ this.onExit(id, reason);
116
+ },
117
+ });
118
+ this.slots.set(id, { state: "booting", link });
119
+ break;
120
+ }
121
+ case "dispatch": {
122
+ const waiter = this.waiting.shift();
123
+ if (waiter === undefined) {
124
+ return;
125
+ }
126
+ this.slots.set(move.workerId, { state: "busy", link: move.link });
127
+ // The worker goes idle before the caller sees the result, so an immediate next call reuses it.
128
+ void move.link.request(waiter.query).then((result) => {
129
+ this.onSettled(move.workerId);
130
+ waiter.resolve(result);
131
+ }, (error) => {
132
+ this.onSettled(move.workerId);
133
+ waiter.reject(error);
134
+ });
135
+ break;
136
+ }
137
+ default: {
138
+ const leftover = move;
139
+ throw new Error(`unexpected fleet move ${JSON.stringify(leftover)}`);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ onSettled(id) {
145
+ const slot = this.slots.get(id);
146
+ if (slot?.state === "busy") {
147
+ this.slots.set(id, { state: "idle", link: slot.link });
148
+ }
149
+ this.pump();
150
+ }
151
+ onReady(id) {
152
+ const slot = this.slots.get(id);
153
+ if (slot?.state !== "booting") {
154
+ return;
155
+ }
156
+ this.slots.set(id, { state: "idle", link: slot.link });
157
+ this.pump();
158
+ }
159
+ /**
160
+ * A worker that died booting fails the oldest waiting request with its reason.
161
+ * Load failures are deterministic (no python3, a missing module, weights that do not load),
162
+ * and failing one request per failed boot bounds the respawn loop by the upstream window.
163
+ * A worker that died busy fails only its own request, which the link rejects.
164
+ */
165
+ onExit(id, reason) {
166
+ const slot = this.slots.get(id);
167
+ if (slot === undefined) {
168
+ return;
169
+ }
170
+ this.slots.delete(id);
171
+ if (slot.state === "booting") {
172
+ this.waiting.shift()?.reject(new Error(reason));
173
+ }
174
+ this.pump();
175
+ }
176
+ }