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.
@@ -0,0 +1,73 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { asRecord } from "./disk.js";
5
+ const serverName = "poddrill";
6
+ /** `package.json` one directory above the running module. */
7
+ export async function readPackageIdentity(moduleUrl) {
8
+ const root = dirname(dirname(fileURLToPath(moduleUrl)));
9
+ const text = await readFile(join(root, "package.json"), "utf8");
10
+ const identity = parseManifest(root, text);
11
+ if (identity === undefined) {
12
+ throw new Error(`package.json at ${root} must name ${serverName}, pin a numeric version, and pin cactusNeedle`);
13
+ }
14
+ return identity;
15
+ }
16
+ function parsePackageVersion(text) {
17
+ if (!isPackageVersion(text)) {
18
+ return undefined;
19
+ }
20
+ return text;
21
+ }
22
+ function isPackageVersion(text) {
23
+ return /^\d+\.\d+\.\d+$/.test(text);
24
+ }
25
+ function parseManifest(root, text) {
26
+ const fields = readManifestFields(text);
27
+ if (fields === undefined) {
28
+ return undefined;
29
+ }
30
+ return identityFromFields(root, fields.name, fields.version, fields.cactusNeedle, fields.files);
31
+ }
32
+ function readManifestFields(text) {
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(text);
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ const record = asRecord(parsed);
41
+ if (record === undefined) {
42
+ return undefined;
43
+ }
44
+ const name = record["name"];
45
+ const version = record["version"];
46
+ const cactusNeedle = record["cactusNeedle"];
47
+ if (typeof name !== "string" || typeof version !== "string" || typeof cactusNeedle !== "string") {
48
+ return undefined;
49
+ }
50
+ const filesValue = record["files"];
51
+ if (filesValue === undefined) {
52
+ return { name, version, cactusNeedle, files: [] };
53
+ }
54
+ if (!Array.isArray(filesValue)) {
55
+ return undefined;
56
+ }
57
+ const files = [];
58
+ for (const entry of filesValue) {
59
+ if (typeof entry !== "string") {
60
+ return undefined;
61
+ }
62
+ files.push(entry);
63
+ }
64
+ return { name, version, cactusNeedle, files };
65
+ }
66
+ function identityFromFields(root, name, versionText, cactusNeedle, files) {
67
+ const version = parsePackageVersion(versionText);
68
+ if (name !== serverName || version === undefined || cactusNeedle.length === 0) {
69
+ return undefined;
70
+ }
71
+ const spec = `${serverName}@${version}`;
72
+ return { name: serverName, version, spec, cactusNeedle, root, files };
73
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Hosts that run a transcript model. The episode module never imports this file.
3
+ * A new host is a new id in `providerIds` and a `WhistleHost` factory.
4
+ */
5
+ import { openFleet } from "./fleet.js";
6
+ import { fleetSize, modelForLanguage } from "./models.js";
7
+ import { moonshineFailureMessage, spawnMoonshineWorker } from "./moonshine.js";
8
+ import { spawnNeedleWorker } from "./needle.js";
9
+ export const providerIds = ["localhost", "grok-bot", "cursor-cloud"];
10
+ export function isProviderId(value) {
11
+ return providerIds.includes(value);
12
+ }
13
+ /**
14
+ * `undefined` when this model can run.
15
+ * A missing Moonshine weight path fails here and does not download. A present path runs the worker.
16
+ */
17
+ export function transcriptRefusal(model, weights) {
18
+ switch (model.engine) {
19
+ case "whistle":
20
+ return undefined;
21
+ case "moonshine":
22
+ return weights[model.id] === undefined ? `${model.id} has no weights path` : undefined;
23
+ default: {
24
+ const leftover = model;
25
+ throw new Error(`unexpected model ${JSON.stringify(leftover)}`);
26
+ }
27
+ }
28
+ }
29
+ /**
30
+ * Mac runs one warm pool per model id. Nothing spawns until that id's first slice.
31
+ * Grok Bot's computer and Cursor's cloud computer are not wired yet.
32
+ */
33
+ const factories = {
34
+ localhost: (config, whistleSpawn, moonshineSpawn) => openMacHost(config, whistleSpawn, moonshineSpawn),
35
+ "grok-bot": () => unwiredHost("grok-bot"),
36
+ "cursor-cloud": () => unwiredHost("cursor-cloud"),
37
+ };
38
+ /** Open the host selected at setup. Spawns nothing until the first slice of a model. */
39
+ export function openWhistleCall(providerId, config, whistleSpawn = spawnNeedleWorker, moonshineSpawn = spawnMoonshineWorker) {
40
+ if (!Number.isInteger(config.fleetLimit) || config.fleetLimit < 1) {
41
+ throw new Error(`fleet limit must be a positive integer, got ${config.fleetLimit}`);
42
+ }
43
+ return factories[providerId](config, whistleSpawn, moonshineSpawn);
44
+ }
45
+ function openMacHost(config, whistleSpawn, moonshineSpawn) {
46
+ const pools = new Map();
47
+ const poolFor = (model) => {
48
+ const existing = pools.get(model.id);
49
+ if (existing !== undefined) {
50
+ return existing;
51
+ }
52
+ const pool = openFleet({
53
+ limit: fleetSize(model.id, config),
54
+ spawn: spawnFor(model, config.modelWeights, whistleSpawn, moonshineSpawn),
55
+ closedMessage: "whistle host is closed",
56
+ });
57
+ pools.set(model.id, pool);
58
+ return pool;
59
+ };
60
+ return {
61
+ transcribeSlice(job) {
62
+ const model = modelForLanguage(job.language);
63
+ if (model === undefined) {
64
+ return Promise.reject(new Error(`unknown language ${job.language}`));
65
+ }
66
+ const refusal = transcriptRefusal(model, config.modelWeights);
67
+ if (refusal !== undefined) {
68
+ return Promise.reject(new Error(refusal));
69
+ }
70
+ return poolFor(model).run({ wavPath: job.wavPath, language: job.language });
71
+ },
72
+ async close() {
73
+ await Promise.all([...pools.values()].map((pool) => pool.close()));
74
+ },
75
+ release() {
76
+ let clear = true;
77
+ for (const pool of pools.values()) {
78
+ if (!pool.release()) {
79
+ clear = false;
80
+ }
81
+ }
82
+ return clear;
83
+ },
84
+ };
85
+ }
86
+ function spawnFor(model, weights, whistleSpawn, moonshineSpawn) {
87
+ switch (model.engine) {
88
+ case "whistle":
89
+ return whistleSpawn;
90
+ case "moonshine": {
91
+ const weightsDir = weights[model.id];
92
+ if (weightsDir === undefined) {
93
+ throw new Error(`${model.id} has no weights path`);
94
+ }
95
+ const spawn = moonshineSpawn(model, weightsDir);
96
+ return (events) => {
97
+ const link = spawn(events);
98
+ return {
99
+ request: (query) => link.request({ wavPath: query.wavPath }).catch((error) => {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ return Promise.reject(new Error(moonshineFailureMessage(message)));
102
+ }),
103
+ close: () => link.close(),
104
+ };
105
+ };
106
+ }
107
+ default: {
108
+ const leftover = model;
109
+ throw new Error(`unexpected model ${JSON.stringify(leftover)}`);
110
+ }
111
+ }
112
+ }
113
+ function unwiredHost(providerId) {
114
+ return {
115
+ async transcribeSlice() {
116
+ throw new Error(`${providerId} provider transport is not wired`);
117
+ },
118
+ async close() { },
119
+ release() {
120
+ return true;
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The embedding unit, not a speaker rule. Cues join while the silence between them is at most
3
+ * `STRETCH_GAP_MS` and the joined span is at most `STRETCH_MAX_MS`. A joined span shorter than
4
+ * `STRETCH_MIN_MS` is too short for a stable vector and is dropped.
5
+ */
6
+ export const STRETCH_GAP_MS = 500;
7
+ export const STRETCH_MAX_MS = 14_000;
8
+ export const STRETCH_MIN_MS = 4_000;
9
+ /** Cue spans in file order, from the WebVTT that `projectSlices` renders. */
10
+ export function readCueSpans(vtt) {
11
+ const spans = [];
12
+ for (const line of vtt.split("\n")) {
13
+ const match = /^(\d{2}):(\d{2}):(\d{2})\.(\d{3}) --> (\d{2}):(\d{2}):(\d{2})\.(\d{3})$/.exec(line);
14
+ if (match === null) {
15
+ continue;
16
+ }
17
+ const [, sh, sm, ss, sms, eh, em, es, ems] = match;
18
+ spans.push({ startMs: stampMs(sh, sm, ss, sms), endMs: stampMs(eh, em, es, ems) });
19
+ }
20
+ return spans;
21
+ }
22
+ export function planStretches(cues) {
23
+ const joined = [];
24
+ let open;
25
+ for (const cue of cues) {
26
+ if (open !== undefined &&
27
+ cue.startMs - open.endMs <= STRETCH_GAP_MS &&
28
+ cue.endMs - open.startMs <= STRETCH_MAX_MS) {
29
+ open = { startMs: open.startMs, endMs: Math.max(open.endMs, cue.endMs) };
30
+ continue;
31
+ }
32
+ if (open !== undefined) {
33
+ joined.push(open);
34
+ }
35
+ open = cue;
36
+ }
37
+ if (open !== undefined) {
38
+ joined.push(open);
39
+ }
40
+ return joined
41
+ .filter((span) => span.endMs - span.startMs >= STRETCH_MIN_MS)
42
+ .map((span, index) => ({ index, startMs: span.startMs, endMs: span.endMs }));
43
+ }
44
+ function stampMs(hours, minutes, seconds, millis) {
45
+ return Number(hours) * 3_600_000 + Number(minutes) * 60_000 + Number(seconds) * 1_000 + Number(millis);
46
+ }
@@ -0,0 +1,258 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { parseDocument, stringify } from "yaml";
3
+ import { asRecord } from "./disk.js";
4
+ const SUMMARY_ID = /^sm_[0-9a-f]{16}$/;
5
+ const POST_FIELDS = ["text", "cues", "startMs", "endMs"];
6
+ const PUT_FIELDS = ["index", "text", "cues", "startMs", "endMs"];
7
+ const FILE_FIELDS = ["version", "episodeId", "summaryId", "summary", "paragraphs"];
8
+ const PARAGRAPH_FIELDS = ["index", "text", "cues", "startMs", "endMs"];
9
+ const parseOptions = {
10
+ schema: "core",
11
+ uniqueKeys: true,
12
+ maxAliasCount: 0,
13
+ };
14
+ const dumpOptions = {
15
+ aliasDuplicateObjects: false,
16
+ lineWidth: 0,
17
+ schema: "core",
18
+ };
19
+ export function freshSummaryId() {
20
+ return asSummaryId(`sm_${randomBytes(8).toString("hex")}`);
21
+ }
22
+ export function parseSummaryId(value) {
23
+ return SUMMARY_ID.test(value) ? asSummaryId(value) : undefined;
24
+ }
25
+ export function requireSummaryText(summary) {
26
+ if (typeof summary !== "string" || summary === "") {
27
+ return { ok: false, message: "summary must be a non-empty string" };
28
+ }
29
+ return { ok: true, value: summary };
30
+ }
31
+ export function emptySummary(episodeId, summaryId) {
32
+ return {
33
+ version: 1,
34
+ episodeId,
35
+ summaryId,
36
+ summary: "",
37
+ paragraphs: [],
38
+ };
39
+ }
40
+ export function dumpSummary(document) {
41
+ const dumped = stringify({
42
+ version: document.version,
43
+ episodeId: document.episodeId,
44
+ summaryId: document.summaryId,
45
+ summary: document.summary,
46
+ paragraphs: document.paragraphs.map((paragraph) => ({
47
+ index: paragraph.index,
48
+ text: paragraph.text,
49
+ cues: paragraph.cues,
50
+ startMs: paragraph.startMs,
51
+ endMs: paragraph.endMs,
52
+ })),
53
+ }, dumpOptions);
54
+ return dumped;
55
+ }
56
+ export function parseSummaryFile(text, episodeId, summaryId) {
57
+ const doc = parseDocument(text, parseOptions);
58
+ // yaml 2 reports an unresolved tag such as !!js/function as a warning, not an error.
59
+ if (doc.errors.length > 0 || doc.warnings.length > 0) {
60
+ return undefined;
61
+ }
62
+ let value;
63
+ try {
64
+ value = doc.toJS({ maxAliasCount: 0 });
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ return narrowSummary(value, episodeId, summaryId);
70
+ }
71
+ export function inspectPostBody(body) {
72
+ const record = asRecord(body);
73
+ if (record === undefined) {
74
+ return { ok: false, message: "postParagraph expects { text, cues }" };
75
+ }
76
+ if (Object.hasOwn(record, "index")) {
77
+ return { ok: false, message: "postParagraph rejects index" };
78
+ }
79
+ return readCueSet(record, POST_FIELDS, "postParagraph expects { text, cues }");
80
+ }
81
+ export function inspectPutBody(body) {
82
+ const record = asRecord(body);
83
+ if (record === undefined || !Object.hasOwn(record, "index")) {
84
+ return { ok: false, message: "putParagraph requires index" };
85
+ }
86
+ const index = positiveIndex(record["index"]);
87
+ if (index === undefined) {
88
+ return { ok: false, message: "putParagraph requires index" };
89
+ }
90
+ const span = readCueSet(record, PUT_FIELDS, "putParagraph expects { index, text, cues }");
91
+ if (!span.ok) {
92
+ return span;
93
+ }
94
+ return { ok: true, value: { index, ...span.value } };
95
+ }
96
+ export function orderCueSet(ids, startMs, endMs, cues) {
97
+ if (ids.length === 0) {
98
+ return { ok: false, message: "cues must list at least one cue" };
99
+ }
100
+ const seen = new Set();
101
+ const indexes = [];
102
+ for (const id of ids) {
103
+ if (seen.has(id)) {
104
+ return { ok: false, message: "a cue is listed twice" };
105
+ }
106
+ seen.add(id);
107
+ const index = cues.findIndex((cue) => cue.id === id);
108
+ if (index < 0) {
109
+ return { ok: false, message: "a cue is not in the transcript" };
110
+ }
111
+ indexes.push(index);
112
+ }
113
+ indexes.sort((left, right) => left - right);
114
+ const ordered = [];
115
+ let first;
116
+ let last;
117
+ for (const index of indexes) {
118
+ const cue = cues[index];
119
+ if (cue === undefined) {
120
+ return { ok: false, message: "a cue is not in the transcript" };
121
+ }
122
+ ordered.push(cue.id);
123
+ if (first === undefined) {
124
+ first = cue;
125
+ }
126
+ last = cue;
127
+ }
128
+ if (first === undefined || last === undefined) {
129
+ return { ok: false, message: "cues must list at least one cue" };
130
+ }
131
+ if (startMs !== undefined && startMs !== first.startMs) {
132
+ return { ok: false, message: "startMs does not match the cue" };
133
+ }
134
+ if (endMs !== undefined && endMs !== last.endMs) {
135
+ return { ok: false, message: "endMs does not match the cue" };
136
+ }
137
+ return { ok: true, value: { cues: ordered, startMs: first.startMs, endMs: last.endMs } };
138
+ }
139
+ function narrowSummary(value, episodeId, summaryId) {
140
+ const record = asRecord(value);
141
+ if (record === undefined || !exactKeys(record, FILE_FIELDS)) {
142
+ return undefined;
143
+ }
144
+ const summary = record["summary"];
145
+ const paragraphs = record["paragraphs"];
146
+ if (record["version"] !== 1 ||
147
+ record["episodeId"] !== episodeId ||
148
+ record["summaryId"] !== summaryId ||
149
+ typeof summary !== "string" ||
150
+ !Array.isArray(paragraphs)) {
151
+ return undefined;
152
+ }
153
+ const parsed = [];
154
+ for (let position = 0; position < paragraphs.length; position += 1) {
155
+ const paragraph = narrowParagraph(paragraphs[position], position + 1);
156
+ if (paragraph === undefined) {
157
+ return undefined;
158
+ }
159
+ parsed.push(paragraph);
160
+ }
161
+ return {
162
+ version: 1,
163
+ episodeId,
164
+ summaryId,
165
+ summary,
166
+ paragraphs: parsed,
167
+ };
168
+ }
169
+ function narrowParagraph(value, index) {
170
+ const record = asRecord(value);
171
+ if (record === undefined || !exactKeys(record, PARAGRAPH_FIELDS)) {
172
+ return undefined;
173
+ }
174
+ const text = record["text"];
175
+ const cues = cueIdList(record["cues"]);
176
+ const startMs = record["startMs"];
177
+ const endMs = record["endMs"];
178
+ if (record["index"] !== index ||
179
+ typeof text !== "string" ||
180
+ text === "" ||
181
+ cues === undefined ||
182
+ typeof startMs !== "number" ||
183
+ !Number.isFinite(startMs) ||
184
+ typeof endMs !== "number" ||
185
+ !Number.isFinite(endMs)) {
186
+ return undefined;
187
+ }
188
+ return { index, text, cues, startMs, endMs };
189
+ }
190
+ function readCueSet(record, fields, message) {
191
+ for (const key of Object.keys(record)) {
192
+ if (key === "index") {
193
+ continue;
194
+ }
195
+ if (!fields.includes(key)) {
196
+ return { ok: false, message };
197
+ }
198
+ }
199
+ const text = nonEmpty(record["text"]);
200
+ const cues = cueIdList(record["cues"]);
201
+ if (text === undefined || cues === undefined) {
202
+ return { ok: false, message };
203
+ }
204
+ const startMs = optionalMs(record, "startMs");
205
+ if (!startMs.ok) {
206
+ return startMs;
207
+ }
208
+ const endMs = optionalMs(record, "endMs");
209
+ if (!endMs.ok) {
210
+ return endMs;
211
+ }
212
+ return {
213
+ ok: true,
214
+ value: { text, cues, startMs: startMs.value, endMs: endMs.value },
215
+ };
216
+ }
217
+ function cueIdList(value) {
218
+ if (!Array.isArray(value) || value.length === 0) {
219
+ return undefined;
220
+ }
221
+ const ids = [];
222
+ for (const item of value) {
223
+ if (typeof item !== "string" || item === "") {
224
+ return undefined;
225
+ }
226
+ ids.push(item);
227
+ }
228
+ return ids;
229
+ }
230
+ function optionalMs(record, key) {
231
+ if (!Object.hasOwn(record, key)) {
232
+ return { ok: true, value: undefined };
233
+ }
234
+ const value = record[key];
235
+ if (typeof value !== "number" || !Number.isFinite(value)) {
236
+ return { ok: false, message: `${key} must be a number` };
237
+ }
238
+ return { ok: true, value };
239
+ }
240
+ function nonEmpty(value) {
241
+ if (typeof value !== "string" || value === "") {
242
+ return undefined;
243
+ }
244
+ return value;
245
+ }
246
+ function positiveIndex(value) {
247
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
248
+ return undefined;
249
+ }
250
+ return value;
251
+ }
252
+ function exactKeys(record, keys) {
253
+ const found = Object.keys(record);
254
+ return found.length === keys.length && keys.every((key) => Object.hasOwn(record, key));
255
+ }
256
+ function asSummaryId(value) {
257
+ return value;
258
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Public transcribe entry.
3
+ * Planning and WebVTT live in episode.ts. Hosts live in provider.ts.
4
+ */
5
+ import { planSlices, projectSlices, } from "./episode.js";
6
+ import { isProviderId, openWhistleCall } from "./provider.js";
7
+ /** The installing agent calls this once. Later transcribe calls read the same id. */
8
+ export function selectProvider(providerId) {
9
+ if (!isProviderId(providerId)) {
10
+ throw new Error(`unknown provider ${providerId}`);
11
+ }
12
+ return { providerId };
13
+ }
14
+ /**
15
+ * Run Whistle on slices that were already cut, then project WebVTT.
16
+ * The host is the profile from setup, not an argument that changes the cutter.
17
+ */
18
+ export async function transcribePrepared(input) {
19
+ const language = input.language ?? "en";
20
+ const host = openWhistleCall(input.profile.providerId, {
21
+ fleetLimit: input.fleetLimit,
22
+ modelFleet: input.modelFleet ?? {},
23
+ modelWeights: input.modelWeights ?? {},
24
+ });
25
+ try {
26
+ const records = [];
27
+ for (const slice of input.slices) {
28
+ const job = {
29
+ sliceIndex: slice.index,
30
+ startMs: slice.startMs,
31
+ wavPath: slice.wavPath,
32
+ language,
33
+ };
34
+ const whistle = await host.transcribeSlice(job);
35
+ records.push({
36
+ plan: {
37
+ index: slice.index,
38
+ startMs: slice.startMs,
39
+ endMs: slice.endMs,
40
+ keepStartMs: slice.keepStartMs,
41
+ keepEndMs: slice.keepEndMs,
42
+ },
43
+ slicePath: slice.wavPath,
44
+ whistle,
45
+ });
46
+ }
47
+ return projectSlices(records);
48
+ }
49
+ finally {
50
+ await host.close();
51
+ }
52
+ }
53
+ export { planSlices, projectSlices };