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/.agents/skills/poddrill/SKILL.md +96 -0
- package/.agents/skills/poddrill/references/assign-speakers.md +22 -0
- package/.agents/skills/poddrill/references/create-summary.md +140 -0
- package/.agents/skills/poddrill/references/fetch-audio.md +40 -0
- package/.agents/skills/poddrill/references/orwell.md +71 -0
- package/.agents/skills/poddrill/references/select-models.md +58 -0
- package/.agents/skills/poddrill/references/track-sources.md +59 -0
- package/.agents/skills/poddrill/scripts/fetch-audio.mjs +154 -0
- package/LICENSE +21 -0
- package/README.md +28 -2
- package/dist/audio.js +262 -0
- package/dist/audit.js +834 -0
- package/dist/cli.js +455 -0
- package/dist/client.js +1450 -0
- package/dist/disk.js +47 -0
- package/dist/embed.js +127 -0
- package/dist/episode.js +259 -0
- package/dist/fleet.js +176 -0
- package/dist/groups.js +512 -0
- package/dist/idle-fleet.js +71 -0
- package/dist/jobs.js +1053 -0
- package/dist/models.js +45 -0
- package/dist/moonshine.js +185 -0
- package/dist/needle.js +42 -0
- package/dist/owner.js +770 -0
- package/dist/package-identity.js +73 -0
- package/dist/provider.js +123 -0
- package/dist/stretch.js +46 -0
- package/dist/summary.js +258 -0
- package/dist/transcribe.js +53 -0
- package/dist/worker.js +215 -0
- package/dist/workspace.js +306 -0
- package/package.json +31 -4
package/README.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
-
#
|
|
1
|
+
# PodDrill
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
PodDrill transcribes episode audio into timed text, groups the voices, and stores a summary.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
Use Node.js 22.18 or newer.
|
|
8
|
+
|
|
9
|
+
A transcript also needs ffmpeg and the Python package cactus-needle 3.2.0. That Python package is not the npm package named cactus-needle.
|
|
10
|
+
|
|
11
|
+
## Run
|
|
12
|
+
|
|
13
|
+
From this repository, run the command.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
node src/cli.ts
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`npm run build` writes `dist`. The published package runs `dist/cli.js`, because Node does not strip TypeScript inside `node_modules`.
|
|
20
|
+
|
|
21
|
+
The npm package is not published yet. After it is published, run `npx poddrill`.
|
|
22
|
+
|
|
23
|
+
## Workspace
|
|
24
|
+
|
|
25
|
+
Set `PODDRILL_WORKSPACE` to a directory outside this repository. Then run `poddrill setup` in that directory.
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
MIT License. Copyright (c) 2026 Jianyu Zhao.
|
package/dist/audio.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio duration and slicing. Host-independent. No provider imports.
|
|
3
|
+
* 16-bit PCM mono 16 kHz WAV is copied sample for sample. Every other input is transcoded with ffmpeg.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { open, writeFile } from "node:fs/promises";
|
|
7
|
+
import { SLICE_MS } from "./episode.js";
|
|
8
|
+
/** Whistle's input rate. The copy path cannot resample. */
|
|
9
|
+
const SAMPLE_RATE = 16_000;
|
|
10
|
+
const SAMPLES_PER_MS = SAMPLE_RATE / 1000;
|
|
11
|
+
const BYTES_PER_SAMPLE = 2;
|
|
12
|
+
const PCM_FORMAT = 1;
|
|
13
|
+
/** Integer milliseconds, or `undefined` when the file has no decodable audio. */
|
|
14
|
+
export async function probeDurationMs(path) {
|
|
15
|
+
const handle = await open(path, "r");
|
|
16
|
+
try {
|
|
17
|
+
const pcm = await readPcmData(handle, path);
|
|
18
|
+
return pcm === undefined ? undefined : Math.floor((pcm.sampleCount * 1000) / SAMPLE_RATE);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (!isFormatGap(error)) {
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await handle.close();
|
|
27
|
+
}
|
|
28
|
+
return durationMsFromBanner(await ffmpegBanner(path));
|
|
29
|
+
}
|
|
30
|
+
function durationMsFromBanner(report) {
|
|
31
|
+
const match = /Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)/.exec(report);
|
|
32
|
+
if (match === null) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const hours = match[1];
|
|
36
|
+
const minutes = match[2];
|
|
37
|
+
const seconds = match[3];
|
|
38
|
+
if (hours === undefined || minutes === undefined || seconds === undefined) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const total = Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
|
|
42
|
+
if (!Number.isFinite(total)) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return Math.floor(total * 1000);
|
|
46
|
+
}
|
|
47
|
+
function ffmpegBanner(path) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
let settled = false;
|
|
50
|
+
const finish = (error, report) => {
|
|
51
|
+
if (settled) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
settled = true;
|
|
55
|
+
if (error !== undefined) {
|
|
56
|
+
reject(error);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
resolve(report);
|
|
60
|
+
};
|
|
61
|
+
const child = spawn("ffmpeg", ["-hide_banner", "-nostdin", "-i", path], {
|
|
62
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
63
|
+
});
|
|
64
|
+
const stderr = [];
|
|
65
|
+
const stderrStream = child.stderr;
|
|
66
|
+
if (stderrStream !== null) {
|
|
67
|
+
stderrStream.on("data", (chunk) => {
|
|
68
|
+
stderr.push(chunk);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
child.on("error", (error) => {
|
|
72
|
+
if (spawnCode(error) === "ENOENT") {
|
|
73
|
+
finish(new Error("ffmpeg is not installed"), "");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
finish(error, "");
|
|
77
|
+
});
|
|
78
|
+
// ffmpeg -i exits 1 after it prints Duration. The banner is the result.
|
|
79
|
+
child.on("close", () => {
|
|
80
|
+
finish(undefined, Buffer.concat(stderr).toString("utf8"));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** Write `slice.startMs`..`slice.endMs` of `source` to `outPath` as 16 kHz mono WAV. */
|
|
85
|
+
export async function cutSlice(source, slice, outPath) {
|
|
86
|
+
try {
|
|
87
|
+
await copyPcmSlice(source, slice, outPath);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (!isFormatGap(error)) {
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
await transcodeSlice(source, slice, outPath);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function copyPcmSlice(source, slice, outPath) {
|
|
97
|
+
const handle = await open(source, "r");
|
|
98
|
+
try {
|
|
99
|
+
const pcm = await readPcmData(handle, source);
|
|
100
|
+
if (pcm === undefined) {
|
|
101
|
+
throw new Error(`${source} has no PCM data`);
|
|
102
|
+
}
|
|
103
|
+
const first = slice.startMs * SAMPLES_PER_MS;
|
|
104
|
+
const last = Math.min(slice.endMs * SAMPLES_PER_MS, pcm.sampleCount);
|
|
105
|
+
if (first >= last) {
|
|
106
|
+
throw new Error(`slice ${slice.index} starts past the end of ${source}`);
|
|
107
|
+
}
|
|
108
|
+
const samples = await readAt(handle, pcm.dataOffset + first * BYTES_PER_SAMPLE, (last - first) * BYTES_PER_SAMPLE);
|
|
109
|
+
await writeFile(outPath, Buffer.concat([wavHeader(samples.length), samples]));
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await handle.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function transcodeSlice(source, slice, outPath) {
|
|
116
|
+
const spanMs = slice.endMs - slice.startMs;
|
|
117
|
+
if (spanMs <= 0) {
|
|
118
|
+
throw new Error(`slice ${slice.index} starts past the end of ${source}`);
|
|
119
|
+
}
|
|
120
|
+
const durationMs = Math.min(spanMs, SLICE_MS);
|
|
121
|
+
await runFfmpeg([
|
|
122
|
+
"-hide_banner",
|
|
123
|
+
"-loglevel",
|
|
124
|
+
"error",
|
|
125
|
+
"-nostdin",
|
|
126
|
+
"-y",
|
|
127
|
+
"-ss",
|
|
128
|
+
seconds(slice.startMs),
|
|
129
|
+
"-t",
|
|
130
|
+
seconds(durationMs),
|
|
131
|
+
"-i",
|
|
132
|
+
source,
|
|
133
|
+
"-ac",
|
|
134
|
+
"1",
|
|
135
|
+
"-ar",
|
|
136
|
+
String(SAMPLE_RATE),
|
|
137
|
+
"-c:a",
|
|
138
|
+
"pcm_s16le",
|
|
139
|
+
// A .tmp suffix does not name a muxer.
|
|
140
|
+
"-f",
|
|
141
|
+
"wav",
|
|
142
|
+
outPath,
|
|
143
|
+
]);
|
|
144
|
+
}
|
|
145
|
+
function seconds(milliseconds) {
|
|
146
|
+
return (milliseconds / 1000).toFixed(3);
|
|
147
|
+
}
|
|
148
|
+
function isFormatGap(error) {
|
|
149
|
+
return error instanceof Error && error.message.startsWith("audio format not implemented:");
|
|
150
|
+
}
|
|
151
|
+
function runFfmpeg(args) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
let settled = false;
|
|
154
|
+
const finish = (error) => {
|
|
155
|
+
if (settled) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
settled = true;
|
|
159
|
+
if (error === undefined) {
|
|
160
|
+
resolve();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
reject(error);
|
|
164
|
+
};
|
|
165
|
+
const child = spawn("ffmpeg", [...args], { stdio: ["ignore", "ignore", "pipe"] });
|
|
166
|
+
const stderr = [];
|
|
167
|
+
const stderrStream = child.stderr;
|
|
168
|
+
if (stderrStream !== null) {
|
|
169
|
+
stderrStream.on("data", (chunk) => {
|
|
170
|
+
stderr.push(chunk);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
child.on("error", (error) => {
|
|
174
|
+
if (spawnCode(error) === "ENOENT") {
|
|
175
|
+
finish(new Error("ffmpeg is not installed"));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
finish(error);
|
|
179
|
+
});
|
|
180
|
+
child.on("close", (code) => {
|
|
181
|
+
if (code === 0) {
|
|
182
|
+
finish(undefined);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const detail = Buffer.concat(stderr).toString("utf8").trim();
|
|
186
|
+
const status = code === null ? "null" : String(code);
|
|
187
|
+
finish(new Error(detail === "" ? `ffmpeg exited with code ${status}` : `ffmpeg: ${detail}`));
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
function spawnCode(error) {
|
|
192
|
+
if (!("code" in error)) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
const code = error.code;
|
|
196
|
+
return typeof code === "string" ? code : undefined;
|
|
197
|
+
}
|
|
198
|
+
async function readPcmData(handle, path) {
|
|
199
|
+
const riff = await readAt(handle, 0, 12);
|
|
200
|
+
if (riff.length < 12 ||
|
|
201
|
+
riff.toString("ascii", 0, 4) !== "RIFF" ||
|
|
202
|
+
riff.toString("ascii", 8, 12) !== "WAVE") {
|
|
203
|
+
throw notImplemented(path, "is not a WAV file");
|
|
204
|
+
}
|
|
205
|
+
const { size } = await handle.stat();
|
|
206
|
+
let supported = false;
|
|
207
|
+
let offset = 12;
|
|
208
|
+
while (offset + 8 <= size) {
|
|
209
|
+
const chunk = await readAt(handle, offset, 8);
|
|
210
|
+
const id = chunk.toString("ascii", 0, 4);
|
|
211
|
+
const chunkSize = chunk.readUInt32LE(4);
|
|
212
|
+
const body = offset + 8;
|
|
213
|
+
if (id === "fmt ") {
|
|
214
|
+
if (chunkSize < 16) {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
const fmt = await readAt(handle, body, 16);
|
|
218
|
+
const format = fmt.readUInt16LE(0);
|
|
219
|
+
const channels = fmt.readUInt16LE(2);
|
|
220
|
+
const sampleRate = fmt.readUInt32LE(4);
|
|
221
|
+
const bits = fmt.readUInt16LE(14);
|
|
222
|
+
if (format !== PCM_FORMAT || channels !== 1 || sampleRate !== SAMPLE_RATE || bits !== 16) {
|
|
223
|
+
throw notImplemented(path, `is format ${format}, ${channels} channels, ${sampleRate} Hz, ${bits}-bit`);
|
|
224
|
+
}
|
|
225
|
+
supported = true;
|
|
226
|
+
}
|
|
227
|
+
else if (id === "data") {
|
|
228
|
+
if (!supported) {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
const bytes = Math.min(chunkSize, size - body);
|
|
232
|
+
return { dataOffset: body, sampleCount: Math.floor(bytes / BYTES_PER_SAMPLE) };
|
|
233
|
+
}
|
|
234
|
+
offset = body + chunkSize + (chunkSize % 2);
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
async function readAt(handle, position, length) {
|
|
239
|
+
const buffer = Buffer.alloc(length);
|
|
240
|
+
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
241
|
+
return buffer.subarray(0, bytesRead);
|
|
242
|
+
}
|
|
243
|
+
function wavHeader(dataBytes) {
|
|
244
|
+
const header = Buffer.alloc(44);
|
|
245
|
+
header.write("RIFF", 0, "ascii");
|
|
246
|
+
header.writeUInt32LE(36 + dataBytes, 4);
|
|
247
|
+
header.write("WAVE", 8, "ascii");
|
|
248
|
+
header.write("fmt ", 12, "ascii");
|
|
249
|
+
header.writeUInt32LE(16, 16);
|
|
250
|
+
header.writeUInt16LE(PCM_FORMAT, 20);
|
|
251
|
+
header.writeUInt16LE(1, 22);
|
|
252
|
+
header.writeUInt32LE(SAMPLE_RATE, 24);
|
|
253
|
+
header.writeUInt32LE(SAMPLE_RATE * BYTES_PER_SAMPLE, 28);
|
|
254
|
+
header.writeUInt16LE(BYTES_PER_SAMPLE, 32);
|
|
255
|
+
header.writeUInt16LE(16, 34);
|
|
256
|
+
header.write("data", 36, "ascii");
|
|
257
|
+
header.writeUInt32LE(dataBytes, 40);
|
|
258
|
+
return header;
|
|
259
|
+
}
|
|
260
|
+
function notImplemented(path, detail) {
|
|
261
|
+
return new Error(`audio format not implemented: ${path} ${detail}. Only 16-bit PCM mono 16 kHz WAV is supported.`);
|
|
262
|
+
}
|