poddrill 0.0.0-stage → 0.1.1
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 +104 -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 +24 -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,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# PodDrill
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
PodDrill is a skill that transcribes episode audio into timed text, groups the voices, and stores a summary.
|
|
4
|
+
|
|
5
|
+
## Say this
|
|
6
|
+
|
|
7
|
+
Copy this to the agent.
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
Set up PodDrill on this computer.
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The npm package is `poddrill`. The agent installs that package and the requirements below, then follows the skill shipped in the package.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
The computer that runs PodDrill needs Node.js 22.18 or newer, `ffmpeg` on `PATH`, and the Python package `cactus-needle` 3.2.0. The npm package named cactus-needle is a different package.
|
|
18
|
+
|
|
19
|
+
That computer also needs the ECAPA weights file. The default path is `~/.cache/poddrill/ecapa/voxceleb_ECAPA512.onnx`. `PODDRILL_EMBED_WEIGHTS` selects another path.
|
|
20
|
+
|
|
21
|
+
Grok Bot cannot write its server entry. It prints the custom server to add. Add that server in the Grok Bot chat.
|
|
22
|
+
|
|
23
|
+
## License
|
|
24
|
+
|
|
25
|
+
MIT License. Copyright 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
|
+
}
|