klaudio 0.11.2 → 0.11.3
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 +96 -96
- package/bin/cli.js +44 -44
- package/package.json +40 -44
- package/src/cache.js +306 -306
- package/src/cli.js +1821 -1821
- package/src/extractor.js +213 -213
- package/src/installer.js +369 -368
- package/src/notify.js +138 -135
- package/src/player.js +488 -488
- package/src/presets.js +87 -87
- package/src/scanner.js +445 -445
- package/src/scumm.js +560 -560
- package/src/tts.js +391 -391
package/src/player.js
CHANGED
|
@@ -1,488 +1,488 @@
|
|
|
1
|
-
import { execFile, spawn } from "node:child_process";
|
|
2
|
-
import { platform } from "node:os";
|
|
3
|
-
import { resolve, extname, basename, join } from "node:path";
|
|
4
|
-
import { open, mkdir, stat } from "node:fs/promises";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
7
|
-
|
|
8
|
-
const MAX_PLAY_SECONDS = 10;
|
|
9
|
-
const FADE_SECONDS = 2; // fade out over last 2 seconds
|
|
10
|
-
|
|
11
|
-
// Formats that Windows MediaPlayer (PresentationCore) can play natively
|
|
12
|
-
const MEDIA_PLAYER_FORMATS = new Set([".wav", ".mp3", ".wma", ".aac"]);
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Determine the best playback strategy for a file on the current OS.
|
|
16
|
-
*/
|
|
17
|
-
function getPlaybackCommand(absPath, { withFade = false, maxSeconds = MAX_PLAY_SECONDS } = {}) {
|
|
18
|
-
const os = platform();
|
|
19
|
-
const ext = extname(absPath).toLowerCase();
|
|
20
|
-
|
|
21
|
-
// ffplay args with optional fade-out and silence-skip
|
|
22
|
-
const ffplayArgs = ["-nodisp", "-autoexit", "-loglevel", "quiet"];
|
|
23
|
-
if (withFade) {
|
|
24
|
-
// silenceremove strips leading silence (below -50dB threshold)
|
|
25
|
-
// afade fades out over last FADE_SECONDS before the maxSeconds cut
|
|
26
|
-
const fadeStart = maxSeconds - FADE_SECONDS;
|
|
27
|
-
const filters = [
|
|
28
|
-
"silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB",
|
|
29
|
-
`afade=t=out:st=${fadeStart}:d=${FADE_SECONDS}`,
|
|
30
|
-
];
|
|
31
|
-
ffplayArgs.push("-af", filters.join(","));
|
|
32
|
-
ffplayArgs.push("-t", String(maxSeconds));
|
|
33
|
-
}
|
|
34
|
-
ffplayArgs.push(absPath);
|
|
35
|
-
|
|
36
|
-
if (os === "darwin") {
|
|
37
|
-
// afplay doesn't support filters — use ffplay if fade needed, fall back to afplay
|
|
38
|
-
if (withFade) {
|
|
39
|
-
return { type: "exec", cmd: "ffplay", args: ffplayArgs, fallback: "afplay" };
|
|
40
|
-
}
|
|
41
|
-
return { type: "exec", cmd: "afplay", args: [absPath] };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (os === "win32") {
|
|
45
|
-
if (withFade || !MEDIA_PLAYER_FORMATS.has(ext)) {
|
|
46
|
-
// Prefer ffplay for fade support and non-native formats; fall back to PowerShell
|
|
47
|
-
return {
|
|
48
|
-
type: "exec",
|
|
49
|
-
cmd: "ffplay",
|
|
50
|
-
args: ffplayArgs,
|
|
51
|
-
fallback: "powershell",
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
return { type: "powershell", absPath };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Linux
|
|
58
|
-
if (ext === ".wav" && !withFade) {
|
|
59
|
-
return { type: "exec", cmd: "aplay", args: [absPath] };
|
|
60
|
-
}
|
|
61
|
-
return {
|
|
62
|
-
type: "exec",
|
|
63
|
-
cmd: "ffplay",
|
|
64
|
-
args: ffplayArgs,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function buildPsCommand(absPath, maxSeconds = 0) {
|
|
69
|
-
const limit = maxSeconds > 0 ? maxSeconds : 30;
|
|
70
|
-
const fadeStart = (limit - FADE_SECONDS) * 10; // in 100ms ticks
|
|
71
|
-
return `
|
|
72
|
-
Add-Type -AssemblyName PresentationCore
|
|
73
|
-
$player = New-Object System.Windows.Media.MediaPlayer
|
|
74
|
-
$player.Open([System.Uri]::new("${absPath.replace(/\\/g, "/")}"))
|
|
75
|
-
Start-Sleep -Milliseconds 300
|
|
76
|
-
$player.Play()
|
|
77
|
-
$player.Volume = 1.0
|
|
78
|
-
$elapsed = 0
|
|
79
|
-
while ($player.Position -lt $player.NaturalDuration.TimeSpan -and $player.NaturalDuration.HasTimeSpan -and $elapsed -lt ${limit * 10}) {
|
|
80
|
-
Start-Sleep -Milliseconds 100
|
|
81
|
-
$elapsed++
|
|
82
|
-
if ($elapsed -gt ${fadeStart} -and ${limit * 10} -gt ${fadeStart}) {
|
|
83
|
-
$remaining = ${limit * 10} - $elapsed
|
|
84
|
-
$total = ${FADE_SECONDS * 10}
|
|
85
|
-
if ($total -gt 0) { $player.Volume = [Math]::Max(0, [double]$remaining / [double]$total) }
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
$player.Stop()
|
|
89
|
-
$player.Close()
|
|
90
|
-
`.trim();
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Get the duration of a WAV file in seconds by reading its header.
|
|
95
|
-
* Returns null if unable to determine.
|
|
96
|
-
*/
|
|
97
|
-
export async function getWavDuration(filePath) {
|
|
98
|
-
const absPath = resolve(filePath);
|
|
99
|
-
const ext = extname(absPath).toLowerCase();
|
|
100
|
-
|
|
101
|
-
// Try ffprobe first (handles all formats and non-standard WAV headers)
|
|
102
|
-
const ffDuration = await getFFprobeDuration(absPath);
|
|
103
|
-
if (ffDuration != null) return ffDuration;
|
|
104
|
-
|
|
105
|
-
// Fallback: parse WAV header directly
|
|
106
|
-
if (ext === ".wav") {
|
|
107
|
-
return getWavDurationFromHeader(absPath);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async function getWavDurationFromHeader(absPath) {
|
|
114
|
-
let fh;
|
|
115
|
-
try {
|
|
116
|
-
fh = await open(absPath, "r");
|
|
117
|
-
const header = Buffer.alloc(44);
|
|
118
|
-
await fh.read(header, 0, 44, 0);
|
|
119
|
-
|
|
120
|
-
// Verify RIFF/WAVE
|
|
121
|
-
if (header.toString("ascii", 0, 4) !== "RIFF") return null;
|
|
122
|
-
if (header.toString("ascii", 8, 12) !== "WAVE") return null;
|
|
123
|
-
|
|
124
|
-
// Read fmt chunk (assuming standard PCM at offset 20)
|
|
125
|
-
const channels = header.readUInt16LE(22);
|
|
126
|
-
const sampleRate = header.readUInt32LE(24);
|
|
127
|
-
const bitsPerSample = header.readUInt16LE(34);
|
|
128
|
-
|
|
129
|
-
if (sampleRate === 0 || channels === 0 || bitsPerSample === 0) return null;
|
|
130
|
-
|
|
131
|
-
// Data chunk size is at offset 40 in standard WAV
|
|
132
|
-
const dataSize = header.readUInt32LE(40);
|
|
133
|
-
const bytesPerSecond = sampleRate * channels * (bitsPerSample / 8);
|
|
134
|
-
|
|
135
|
-
if (bytesPerSecond === 0) return null;
|
|
136
|
-
return Math.round((dataSize / bytesPerSecond) * 10) / 10;
|
|
137
|
-
} catch {
|
|
138
|
-
return null;
|
|
139
|
-
} finally {
|
|
140
|
-
if (fh) await fh.close();
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function getFFprobeDuration(absPath) {
|
|
145
|
-
return new Promise((res) => {
|
|
146
|
-
execFile(
|
|
147
|
-
"ffprobe",
|
|
148
|
-
["-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", absPath],
|
|
149
|
-
{ windowsHide: true, timeout: 5000 },
|
|
150
|
-
(err, stdout) => {
|
|
151
|
-
if (err) return res(null);
|
|
152
|
-
const val = parseFloat(stdout.trim());
|
|
153
|
-
if (isNaN(val)) return res(null);
|
|
154
|
-
res(Math.round(val * 10) / 10);
|
|
155
|
-
}
|
|
156
|
-
);
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Play a sound file. Returns a promise that resolves when playback starts
|
|
162
|
-
* (not when it finishes — we don't want to block).
|
|
163
|
-
*/
|
|
164
|
-
export function playSound(filePath) {
|
|
165
|
-
const absPath = resolve(filePath);
|
|
166
|
-
const strategy = getPlaybackCommand(absPath);
|
|
167
|
-
|
|
168
|
-
return new Promise((resolvePromise) => {
|
|
169
|
-
if (strategy.type === "exec") {
|
|
170
|
-
const child = spawn(strategy.cmd, strategy.args, {
|
|
171
|
-
stdio: "ignore",
|
|
172
|
-
detached: true,
|
|
173
|
-
windowsHide: true,
|
|
174
|
-
});
|
|
175
|
-
child.unref();
|
|
176
|
-
resolvePromise();
|
|
177
|
-
child.on("error", () => {
|
|
178
|
-
if (strategy.fallback === "powershell") {
|
|
179
|
-
const ps = spawn("powershell.exe", ["-NoProfile", "-Command", buildPsCommand(absPath)], {
|
|
180
|
-
stdio: "ignore", detached: true, windowsHide: true,
|
|
181
|
-
});
|
|
182
|
-
ps.unref();
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
} else if (strategy.type === "powershell") {
|
|
186
|
-
const child = spawn("powershell.exe", ["-NoProfile", "-Command", buildPsCommand(absPath)], {
|
|
187
|
-
stdio: "ignore", detached: true, windowsHide: true,
|
|
188
|
-
});
|
|
189
|
-
child.unref();
|
|
190
|
-
resolvePromise();
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Play a sound and wait for it to finish (for preview mode).
|
|
197
|
-
* Returns { promise, cancel } — call cancel() to stop playback immediately.
|
|
198
|
-
* Playback is clamped to MAX_PLAY_SECONDS.
|
|
199
|
-
*/
|
|
200
|
-
export function playSoundWithCancel(filePath, { maxSeconds = MAX_PLAY_SECONDS } = {}) {
|
|
201
|
-
const uncapped = !maxSeconds;
|
|
202
|
-
const absPath = resolve(filePath);
|
|
203
|
-
const strategy = getPlaybackCommand(absPath, { withFade: !uncapped, maxSeconds });
|
|
204
|
-
let childProcess = null;
|
|
205
|
-
let timer = null;
|
|
206
|
-
let cancelled = false;
|
|
207
|
-
|
|
208
|
-
function killChild() {
|
|
209
|
-
if (childProcess && !childProcess.killed) {
|
|
210
|
-
try {
|
|
211
|
-
if (platform() === "win32") {
|
|
212
|
-
// Kill via Node handle first (immediate), then taskkill for child processes
|
|
213
|
-
try { childProcess.kill(); } catch { /* ignore */ }
|
|
214
|
-
spawn("taskkill", ["/pid", String(childProcess.pid), "/f", "/t"], {
|
|
215
|
-
stdio: "ignore", windowsHide: true,
|
|
216
|
-
});
|
|
217
|
-
} else {
|
|
218
|
-
childProcess.kill("SIGTERM");
|
|
219
|
-
}
|
|
220
|
-
} catch { /* ignore */ }
|
|
221
|
-
}
|
|
222
|
-
if (timer) clearTimeout(timer);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const cancel = () => {
|
|
226
|
-
cancelled = true;
|
|
227
|
-
killChild();
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
const promise = new Promise((resolvePromise, reject) => {
|
|
231
|
-
function onDone(err) {
|
|
232
|
-
if (timer) clearTimeout(timer);
|
|
233
|
-
if (cancelled) return resolvePromise(); // cancelled — resolve, don't reject
|
|
234
|
-
if (err) reject(err);
|
|
235
|
-
else resolvePromise();
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function startExec(cmd, args) {
|
|
239
|
-
const execTimeout = uncapped ? 0 : (maxSeconds + 2) * 1000;
|
|
240
|
-
childProcess = execFile(cmd, args, { windowsHide: true, timeout: execTimeout }, (err) => {
|
|
241
|
-
if (err && strategy.fallback && !cancelled) {
|
|
242
|
-
if (strategy.fallback === "powershell") {
|
|
243
|
-
childProcess = execFile(
|
|
244
|
-
"powershell.exe",
|
|
245
|
-
["-NoProfile", "-Command", buildPsCommand(absPath, uncapped ? 0 : maxSeconds)],
|
|
246
|
-
{ windowsHide: true, timeout: execTimeout },
|
|
247
|
-
(psErr) => onDone(psErr)
|
|
248
|
-
);
|
|
249
|
-
} else if (strategy.fallback === "afplay") {
|
|
250
|
-
// macOS: ffplay not available, fall back to afplay (no fade)
|
|
251
|
-
childProcess = execFile("afplay", [absPath], { timeout: execTimeout }, (afErr) => onDone(afErr));
|
|
252
|
-
}
|
|
253
|
-
} else {
|
|
254
|
-
onDone(err);
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
// Set a hard timeout to kill after maxSeconds (skip if uncapped)
|
|
259
|
-
if (!uncapped) {
|
|
260
|
-
timer = setTimeout(() => {
|
|
261
|
-
killChild();
|
|
262
|
-
resolvePromise();
|
|
263
|
-
}, maxSeconds * 1000);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (strategy.type === "exec") {
|
|
268
|
-
startExec(strategy.cmd, strategy.args);
|
|
269
|
-
} else if (strategy.type === "powershell") {
|
|
270
|
-
const execTimeout = uncapped ? 0 : (maxSeconds + 2) * 1000;
|
|
271
|
-
childProcess = execFile(
|
|
272
|
-
"powershell.exe",
|
|
273
|
-
["-NoProfile", "-Command", buildPsCommand(absPath, uncapped ? 0 : maxSeconds)],
|
|
274
|
-
{ windowsHide: true, timeout: execTimeout },
|
|
275
|
-
(err) => onDone(err)
|
|
276
|
-
);
|
|
277
|
-
if (!uncapped) {
|
|
278
|
-
timer = setTimeout(() => {
|
|
279
|
-
killChild();
|
|
280
|
-
resolvePromise();
|
|
281
|
-
}, maxSeconds * 1000);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
const pause = () => {
|
|
287
|
-
if (childProcess && !childProcess.killed && platform() !== "win32") {
|
|
288
|
-
try { process.kill(childProcess.pid, "SIGSTOP"); } catch { /* ignore */ }
|
|
289
|
-
}
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
const resume = () => {
|
|
293
|
-
if (childProcess && !childProcess.killed && platform() !== "win32") {
|
|
294
|
-
try { process.kill(childProcess.pid, "SIGCONT"); } catch { /* ignore */ }
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
return { promise, cancel, pause, resume };
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Play a sound and wait for it to finish (legacy — no cancel support).
|
|
303
|
-
*/
|
|
304
|
-
export function playSoundSync(filePath) {
|
|
305
|
-
return playSoundWithCancel(filePath).promise;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Process a sound file with ffmpeg: strip leading silence, clamp to MAX_PLAY_SECONDS,
|
|
310
|
-
* and fade out over the last FADE_SECONDS. Returns the path to the processed WAV file.
|
|
311
|
-
* If ffmpeg is not available or the file is already short enough, returns the original path.
|
|
312
|
-
*/
|
|
313
|
-
export async function processSound(filePath) {
|
|
314
|
-
const absPath = resolve(filePath);
|
|
315
|
-
|
|
316
|
-
// First check duration — skip processing if already short
|
|
317
|
-
const duration = await getWavDuration(absPath);
|
|
318
|
-
if (duration != null && duration <= MAX_PLAY_SECONDS) {
|
|
319
|
-
return absPath; // Already short enough, no processing needed
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Build a deterministic output path based on input file hash
|
|
323
|
-
const hash = createHash("md5").update(absPath).digest("hex").slice(0, 12);
|
|
324
|
-
const outDir = join(tmpdir(), "klaudio-processed");
|
|
325
|
-
const outName = `${basename(absPath, extname(absPath))}_${hash}.wav`;
|
|
326
|
-
const outPath = join(outDir, outName);
|
|
327
|
-
|
|
328
|
-
// Check if already processed
|
|
329
|
-
try {
|
|
330
|
-
await stat(outPath);
|
|
331
|
-
return outPath; // Already exists
|
|
332
|
-
} catch { /* needs processing */ }
|
|
333
|
-
|
|
334
|
-
await mkdir(outDir, { recursive: true });
|
|
335
|
-
|
|
336
|
-
// Build ffmpeg filter chain: silence strip → fade out → clamp duration
|
|
337
|
-
const fadeStart = MAX_PLAY_SECONDS - FADE_SECONDS;
|
|
338
|
-
const filters = [
|
|
339
|
-
"silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB",
|
|
340
|
-
`afade=t=out:st=${fadeStart}:d=${FADE_SECONDS}`,
|
|
341
|
-
].join(",");
|
|
342
|
-
|
|
343
|
-
return new Promise((res) => {
|
|
344
|
-
execFile(
|
|
345
|
-
"ffmpeg",
|
|
346
|
-
[
|
|
347
|
-
"-y", "-i", absPath,
|
|
348
|
-
"-af", filters,
|
|
349
|
-
"-t", String(MAX_PLAY_SECONDS),
|
|
350
|
-
"-ar", "44100", "-ac", "2",
|
|
351
|
-
outPath,
|
|
352
|
-
],
|
|
353
|
-
{ windowsHide: true, timeout: 30000 },
|
|
354
|
-
(err) => {
|
|
355
|
-
if (err) {
|
|
356
|
-
// ffmpeg not available or failed — return original
|
|
357
|
-
res(absPath);
|
|
358
|
-
} else {
|
|
359
|
-
res(outPath);
|
|
360
|
-
}
|
|
361
|
-
},
|
|
362
|
-
);
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Handle the "play" subcommand: play a sound file and optionally speak a TTS summary.
|
|
368
|
-
* Reads hook JSON from stdin to get last_assistant_message for TTS.
|
|
369
|
-
*/
|
|
370
|
-
export async function handlePlayCommand(args) {
|
|
371
|
-
const soundFile = args.find((a) => !a.startsWith("-"));
|
|
372
|
-
const tts = args.includes("--tts");
|
|
373
|
-
|
|
374
|
-
// Read stdin (hook JSON) non-blocking
|
|
375
|
-
let hookData = {};
|
|
376
|
-
try {
|
|
377
|
-
const chunks = [];
|
|
378
|
-
process.stdin.setEncoding("utf-8");
|
|
379
|
-
// Read whatever is available with a short timeout
|
|
380
|
-
const stdinData = await new Promise((res) => {
|
|
381
|
-
const timer = setTimeout(() => { process.stdin.pause(); res(chunks.join("")); }, 500);
|
|
382
|
-
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
383
|
-
process.stdin.on("end", () => { clearTimeout(timer); res(chunks.join("")); });
|
|
384
|
-
process.stdin.resume();
|
|
385
|
-
});
|
|
386
|
-
if (stdinData.trim()) hookData = JSON.parse(stdinData);
|
|
387
|
-
} catch { /* no stdin or invalid JSON */ }
|
|
388
|
-
|
|
389
|
-
const notify = args.includes("--notify");
|
|
390
|
-
|
|
391
|
-
// Send system notification (detached, never blocks)
|
|
392
|
-
if (notify) {
|
|
393
|
-
const project = hookData.cwd ? hookData.cwd.replace(/\\/g, "/").split("/").pop() : null;
|
|
394
|
-
const notifTitle = project || "klaudio";
|
|
395
|
-
let notifBody = "Task complete";
|
|
396
|
-
if (hookData.last_assistant_message) {
|
|
397
|
-
// Extract first sentence for the notification body
|
|
398
|
-
const plain = hookData.last_assistant_message
|
|
399
|
-
.replace(/```[\s\S]*?```/g, "").replace(/`([^`]+)`/g, "$1")
|
|
400
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1")
|
|
401
|
-
.replace(/#{1,6}\s+/g, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
402
|
-
.replace(/\([^)]*\)/g, "").replace(/\n+/g, " ").trim();
|
|
403
|
-
const first = plain.match(/^[^.!?]*[.!?]/)?.[0] || plain.slice(0, 120);
|
|
404
|
-
notifBody = first.trim();
|
|
405
|
-
} else if (hookData.message) {
|
|
406
|
-
notifBody = hookData.message.slice(0, 120);
|
|
407
|
-
}
|
|
408
|
-
import("./notify.js").then(({ sendNotification }) =>
|
|
409
|
-
sendNotification(notifTitle, notifBody)
|
|
410
|
-
).catch(() => {});
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Play sound (fire and forget, don't wait)
|
|
414
|
-
const soundPromise = soundFile
|
|
415
|
-
? playSoundWithCancel(soundFile).promise.catch(() => {})
|
|
416
|
-
: Promise.resolve();
|
|
417
|
-
|
|
418
|
-
// TTS: speak first 1-2 sentences of last_assistant_message
|
|
419
|
-
if (tts && hookData.last_assistant_message) {
|
|
420
|
-
// Strip markdown syntax and extract first sentence
|
|
421
|
-
const msg = hookData.last_assistant_message
|
|
422
|
-
.replace(/```[\s\S]*?```/g, "") // remove code blocks
|
|
423
|
-
.replace(/`([^`]+)`/g, "$1") // inline code -> text
|
|
424
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1") // **bold** -> text
|
|
425
|
-
.replace(/\*([^*]+)\*/g, "$1") // *italic* -> text
|
|
426
|
-
.replace(/__([^_]+)__/g, "$1") // __bold__ -> text
|
|
427
|
-
.replace(/_([^_]+)_/g, "$1") // _italic_ -> text
|
|
428
|
-
.replace(/#{1,6}\s+/g, "") // headings
|
|
429
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // [links](url) -> text
|
|
430
|
-
.replace(/\([^)]*\)/g, "") // remove parenthesised content
|
|
431
|
-
.replace(/^\s*[-*+]\s+(.*)/gm, "... $1.") // list bullets -> paused items
|
|
432
|
-
.replace(/^\s*\d+\.\s+(.*)/gm, "... $1.") // numbered lists -> paused items
|
|
433
|
-
.replace(/\n+/g, " ") // newlines -> spaces
|
|
434
|
-
.trim();
|
|
435
|
-
// Build summary: include sentences up to ~25 words max.
|
|
436
|
-
// Short next sentences (<4 chars, e.g. version numbers) are always included.
|
|
437
|
-
const MAX_WORDS = 25;
|
|
438
|
-
// Split on sentence-ending punctuation, but not periods between digits (0.8.4)
|
|
439
|
-
// or inside filenames (auth.js). A period is "sentence-ending" only if followed
|
|
440
|
-
// by a space+letter, end-of-string, or another sentence-end mark.
|
|
441
|
-
const sentences = msg.match(/(?:[^.!?]|\.(?=\d|\w{1,5}\b))*[.!?]+/g);
|
|
442
|
-
let summary;
|
|
443
|
-
if (!sentences) {
|
|
444
|
-
summary = msg.split(/\s+/).slice(0, MAX_WORDS).join(" ");
|
|
445
|
-
} else {
|
|
446
|
-
summary = sentences[0].trim();
|
|
447
|
-
for (let i = 1; i < sentences.length; i++) {
|
|
448
|
-
const next = sentences[i].trim();
|
|
449
|
-
const wordsSoFar = summary.split(/\s+/).length;
|
|
450
|
-
const nextWords = next.split(/\s+/).length;
|
|
451
|
-
// Always include tiny fragments (version numbers, short confirmations)
|
|
452
|
-
if (next.length < 4) {
|
|
453
|
-
summary += " " + next;
|
|
454
|
-
continue;
|
|
455
|
-
}
|
|
456
|
-
// Include next sentence if we're still under the word limit
|
|
457
|
-
if (wordsSoFar + nextWords <= MAX_WORDS) {
|
|
458
|
-
summary += " ... " + next;
|
|
459
|
-
} else {
|
|
460
|
-
break;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
// Collapse repeated ellipsis/dots and ensure pauses between sentences
|
|
465
|
-
summary = summary.replace(/\.{2,}/g, "...").replace(/\s{2,}/g, " ");
|
|
466
|
-
// Prefix with project folder name if available
|
|
467
|
-
const project = hookData.cwd ? hookData.cwd.replace(/\\/g, "/").split("/").pop() : null;
|
|
468
|
-
const spoken = project ? `${project}: ${summary}` : summary;
|
|
469
|
-
await soundPromise;
|
|
470
|
-
const { speak } = await import("./tts.js");
|
|
471
|
-
const voice = args.find((a) => a.startsWith("--voice="))?.slice(8)
|
|
472
|
-
|| args[args.indexOf("--voice") + 1];
|
|
473
|
-
await speak(spoken, { voice });
|
|
474
|
-
} else {
|
|
475
|
-
await soundPromise;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* Generate the shell command string for use in Claude Code hooks.
|
|
481
|
-
*/
|
|
482
|
-
export function getHookPlayCommand(soundFilePath, { tts = false, voice, notify = true } = {}) {
|
|
483
|
-
const normalized = soundFilePath.replace(/\\/g, "/");
|
|
484
|
-
const ttsFlag = tts ? " --tts" : "";
|
|
485
|
-
const voiceFlag = tts && voice ? ` --voice ${voice}` : "";
|
|
486
|
-
const notifyFlag = notify ? " --notify" : "";
|
|
487
|
-
return `npx klaudio play "${normalized}"${ttsFlag}${voiceFlag}${notifyFlag}`;
|
|
488
|
-
}
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
import { resolve, extname, basename, join } from "node:path";
|
|
4
|
+
import { open, mkdir, stat } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
const MAX_PLAY_SECONDS = 10;
|
|
9
|
+
const FADE_SECONDS = 2; // fade out over last 2 seconds
|
|
10
|
+
|
|
11
|
+
// Formats that Windows MediaPlayer (PresentationCore) can play natively
|
|
12
|
+
const MEDIA_PLAYER_FORMATS = new Set([".wav", ".mp3", ".wma", ".aac"]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Determine the best playback strategy for a file on the current OS.
|
|
16
|
+
*/
|
|
17
|
+
function getPlaybackCommand(absPath, { withFade = false, maxSeconds = MAX_PLAY_SECONDS } = {}) {
|
|
18
|
+
const os = platform();
|
|
19
|
+
const ext = extname(absPath).toLowerCase();
|
|
20
|
+
|
|
21
|
+
// ffplay args with optional fade-out and silence-skip
|
|
22
|
+
const ffplayArgs = ["-nodisp", "-autoexit", "-loglevel", "quiet"];
|
|
23
|
+
if (withFade) {
|
|
24
|
+
// silenceremove strips leading silence (below -50dB threshold)
|
|
25
|
+
// afade fades out over last FADE_SECONDS before the maxSeconds cut
|
|
26
|
+
const fadeStart = maxSeconds - FADE_SECONDS;
|
|
27
|
+
const filters = [
|
|
28
|
+
"silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB",
|
|
29
|
+
`afade=t=out:st=${fadeStart}:d=${FADE_SECONDS}`,
|
|
30
|
+
];
|
|
31
|
+
ffplayArgs.push("-af", filters.join(","));
|
|
32
|
+
ffplayArgs.push("-t", String(maxSeconds));
|
|
33
|
+
}
|
|
34
|
+
ffplayArgs.push(absPath);
|
|
35
|
+
|
|
36
|
+
if (os === "darwin") {
|
|
37
|
+
// afplay doesn't support filters — use ffplay if fade needed, fall back to afplay
|
|
38
|
+
if (withFade) {
|
|
39
|
+
return { type: "exec", cmd: "ffplay", args: ffplayArgs, fallback: "afplay" };
|
|
40
|
+
}
|
|
41
|
+
return { type: "exec", cmd: "afplay", args: [absPath] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (os === "win32") {
|
|
45
|
+
if (withFade || !MEDIA_PLAYER_FORMATS.has(ext)) {
|
|
46
|
+
// Prefer ffplay for fade support and non-native formats; fall back to PowerShell
|
|
47
|
+
return {
|
|
48
|
+
type: "exec",
|
|
49
|
+
cmd: "ffplay",
|
|
50
|
+
args: ffplayArgs,
|
|
51
|
+
fallback: "powershell",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { type: "powershell", absPath };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Linux
|
|
58
|
+
if (ext === ".wav" && !withFade) {
|
|
59
|
+
return { type: "exec", cmd: "aplay", args: [absPath] };
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
type: "exec",
|
|
63
|
+
cmd: "ffplay",
|
|
64
|
+
args: ffplayArgs,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildPsCommand(absPath, maxSeconds = 0) {
|
|
69
|
+
const limit = maxSeconds > 0 ? maxSeconds : 30;
|
|
70
|
+
const fadeStart = (limit - FADE_SECONDS) * 10; // in 100ms ticks
|
|
71
|
+
return `
|
|
72
|
+
Add-Type -AssemblyName PresentationCore
|
|
73
|
+
$player = New-Object System.Windows.Media.MediaPlayer
|
|
74
|
+
$player.Open([System.Uri]::new("${absPath.replace(/\\/g, "/")}"))
|
|
75
|
+
Start-Sleep -Milliseconds 300
|
|
76
|
+
$player.Play()
|
|
77
|
+
$player.Volume = 1.0
|
|
78
|
+
$elapsed = 0
|
|
79
|
+
while ($player.Position -lt $player.NaturalDuration.TimeSpan -and $player.NaturalDuration.HasTimeSpan -and $elapsed -lt ${limit * 10}) {
|
|
80
|
+
Start-Sleep -Milliseconds 100
|
|
81
|
+
$elapsed++
|
|
82
|
+
if ($elapsed -gt ${fadeStart} -and ${limit * 10} -gt ${fadeStart}) {
|
|
83
|
+
$remaining = ${limit * 10} - $elapsed
|
|
84
|
+
$total = ${FADE_SECONDS * 10}
|
|
85
|
+
if ($total -gt 0) { $player.Volume = [Math]::Max(0, [double]$remaining / [double]$total) }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
$player.Stop()
|
|
89
|
+
$player.Close()
|
|
90
|
+
`.trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get the duration of a WAV file in seconds by reading its header.
|
|
95
|
+
* Returns null if unable to determine.
|
|
96
|
+
*/
|
|
97
|
+
export async function getWavDuration(filePath) {
|
|
98
|
+
const absPath = resolve(filePath);
|
|
99
|
+
const ext = extname(absPath).toLowerCase();
|
|
100
|
+
|
|
101
|
+
// Try ffprobe first (handles all formats and non-standard WAV headers)
|
|
102
|
+
const ffDuration = await getFFprobeDuration(absPath);
|
|
103
|
+
if (ffDuration != null) return ffDuration;
|
|
104
|
+
|
|
105
|
+
// Fallback: parse WAV header directly
|
|
106
|
+
if (ext === ".wav") {
|
|
107
|
+
return getWavDurationFromHeader(absPath);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function getWavDurationFromHeader(absPath) {
|
|
114
|
+
let fh;
|
|
115
|
+
try {
|
|
116
|
+
fh = await open(absPath, "r");
|
|
117
|
+
const header = Buffer.alloc(44);
|
|
118
|
+
await fh.read(header, 0, 44, 0);
|
|
119
|
+
|
|
120
|
+
// Verify RIFF/WAVE
|
|
121
|
+
if (header.toString("ascii", 0, 4) !== "RIFF") return null;
|
|
122
|
+
if (header.toString("ascii", 8, 12) !== "WAVE") return null;
|
|
123
|
+
|
|
124
|
+
// Read fmt chunk (assuming standard PCM at offset 20)
|
|
125
|
+
const channels = header.readUInt16LE(22);
|
|
126
|
+
const sampleRate = header.readUInt32LE(24);
|
|
127
|
+
const bitsPerSample = header.readUInt16LE(34);
|
|
128
|
+
|
|
129
|
+
if (sampleRate === 0 || channels === 0 || bitsPerSample === 0) return null;
|
|
130
|
+
|
|
131
|
+
// Data chunk size is at offset 40 in standard WAV
|
|
132
|
+
const dataSize = header.readUInt32LE(40);
|
|
133
|
+
const bytesPerSecond = sampleRate * channels * (bitsPerSample / 8);
|
|
134
|
+
|
|
135
|
+
if (bytesPerSecond === 0) return null;
|
|
136
|
+
return Math.round((dataSize / bytesPerSecond) * 10) / 10;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
} finally {
|
|
140
|
+
if (fh) await fh.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getFFprobeDuration(absPath) {
|
|
145
|
+
return new Promise((res) => {
|
|
146
|
+
execFile(
|
|
147
|
+
"ffprobe",
|
|
148
|
+
["-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", absPath],
|
|
149
|
+
{ windowsHide: true, timeout: 5000 },
|
|
150
|
+
(err, stdout) => {
|
|
151
|
+
if (err) return res(null);
|
|
152
|
+
const val = parseFloat(stdout.trim());
|
|
153
|
+
if (isNaN(val)) return res(null);
|
|
154
|
+
res(Math.round(val * 10) / 10);
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Play a sound file. Returns a promise that resolves when playback starts
|
|
162
|
+
* (not when it finishes — we don't want to block).
|
|
163
|
+
*/
|
|
164
|
+
export function playSound(filePath) {
|
|
165
|
+
const absPath = resolve(filePath);
|
|
166
|
+
const strategy = getPlaybackCommand(absPath);
|
|
167
|
+
|
|
168
|
+
return new Promise((resolvePromise) => {
|
|
169
|
+
if (strategy.type === "exec") {
|
|
170
|
+
const child = spawn(strategy.cmd, strategy.args, {
|
|
171
|
+
stdio: "ignore",
|
|
172
|
+
detached: true,
|
|
173
|
+
windowsHide: true,
|
|
174
|
+
});
|
|
175
|
+
child.unref();
|
|
176
|
+
resolvePromise();
|
|
177
|
+
child.on("error", () => {
|
|
178
|
+
if (strategy.fallback === "powershell") {
|
|
179
|
+
const ps = spawn("powershell.exe", ["-NoProfile", "-Command", buildPsCommand(absPath)], {
|
|
180
|
+
stdio: "ignore", detached: true, windowsHide: true,
|
|
181
|
+
});
|
|
182
|
+
ps.unref();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
} else if (strategy.type === "powershell") {
|
|
186
|
+
const child = spawn("powershell.exe", ["-NoProfile", "-Command", buildPsCommand(absPath)], {
|
|
187
|
+
stdio: "ignore", detached: true, windowsHide: true,
|
|
188
|
+
});
|
|
189
|
+
child.unref();
|
|
190
|
+
resolvePromise();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Play a sound and wait for it to finish (for preview mode).
|
|
197
|
+
* Returns { promise, cancel } — call cancel() to stop playback immediately.
|
|
198
|
+
* Playback is clamped to MAX_PLAY_SECONDS.
|
|
199
|
+
*/
|
|
200
|
+
export function playSoundWithCancel(filePath, { maxSeconds = MAX_PLAY_SECONDS } = {}) {
|
|
201
|
+
const uncapped = !maxSeconds;
|
|
202
|
+
const absPath = resolve(filePath);
|
|
203
|
+
const strategy = getPlaybackCommand(absPath, { withFade: !uncapped, maxSeconds });
|
|
204
|
+
let childProcess = null;
|
|
205
|
+
let timer = null;
|
|
206
|
+
let cancelled = false;
|
|
207
|
+
|
|
208
|
+
function killChild() {
|
|
209
|
+
if (childProcess && !childProcess.killed) {
|
|
210
|
+
try {
|
|
211
|
+
if (platform() === "win32") {
|
|
212
|
+
// Kill via Node handle first (immediate), then taskkill for child processes
|
|
213
|
+
try { childProcess.kill(); } catch { /* ignore */ }
|
|
214
|
+
spawn("taskkill", ["/pid", String(childProcess.pid), "/f", "/t"], {
|
|
215
|
+
stdio: "ignore", windowsHide: true,
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
childProcess.kill("SIGTERM");
|
|
219
|
+
}
|
|
220
|
+
} catch { /* ignore */ }
|
|
221
|
+
}
|
|
222
|
+
if (timer) clearTimeout(timer);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const cancel = () => {
|
|
226
|
+
cancelled = true;
|
|
227
|
+
killChild();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const promise = new Promise((resolvePromise, reject) => {
|
|
231
|
+
function onDone(err) {
|
|
232
|
+
if (timer) clearTimeout(timer);
|
|
233
|
+
if (cancelled) return resolvePromise(); // cancelled — resolve, don't reject
|
|
234
|
+
if (err) reject(err);
|
|
235
|
+
else resolvePromise();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function startExec(cmd, args) {
|
|
239
|
+
const execTimeout = uncapped ? 0 : (maxSeconds + 2) * 1000;
|
|
240
|
+
childProcess = execFile(cmd, args, { windowsHide: true, timeout: execTimeout }, (err) => {
|
|
241
|
+
if (err && strategy.fallback && !cancelled) {
|
|
242
|
+
if (strategy.fallback === "powershell") {
|
|
243
|
+
childProcess = execFile(
|
|
244
|
+
"powershell.exe",
|
|
245
|
+
["-NoProfile", "-Command", buildPsCommand(absPath, uncapped ? 0 : maxSeconds)],
|
|
246
|
+
{ windowsHide: true, timeout: execTimeout },
|
|
247
|
+
(psErr) => onDone(psErr)
|
|
248
|
+
);
|
|
249
|
+
} else if (strategy.fallback === "afplay") {
|
|
250
|
+
// macOS: ffplay not available, fall back to afplay (no fade)
|
|
251
|
+
childProcess = execFile("afplay", [absPath], { timeout: execTimeout }, (afErr) => onDone(afErr));
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
onDone(err);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Set a hard timeout to kill after maxSeconds (skip if uncapped)
|
|
259
|
+
if (!uncapped) {
|
|
260
|
+
timer = setTimeout(() => {
|
|
261
|
+
killChild();
|
|
262
|
+
resolvePromise();
|
|
263
|
+
}, maxSeconds * 1000);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (strategy.type === "exec") {
|
|
268
|
+
startExec(strategy.cmd, strategy.args);
|
|
269
|
+
} else if (strategy.type === "powershell") {
|
|
270
|
+
const execTimeout = uncapped ? 0 : (maxSeconds + 2) * 1000;
|
|
271
|
+
childProcess = execFile(
|
|
272
|
+
"powershell.exe",
|
|
273
|
+
["-NoProfile", "-Command", buildPsCommand(absPath, uncapped ? 0 : maxSeconds)],
|
|
274
|
+
{ windowsHide: true, timeout: execTimeout },
|
|
275
|
+
(err) => onDone(err)
|
|
276
|
+
);
|
|
277
|
+
if (!uncapped) {
|
|
278
|
+
timer = setTimeout(() => {
|
|
279
|
+
killChild();
|
|
280
|
+
resolvePromise();
|
|
281
|
+
}, maxSeconds * 1000);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const pause = () => {
|
|
287
|
+
if (childProcess && !childProcess.killed && platform() !== "win32") {
|
|
288
|
+
try { process.kill(childProcess.pid, "SIGSTOP"); } catch { /* ignore */ }
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const resume = () => {
|
|
293
|
+
if (childProcess && !childProcess.killed && platform() !== "win32") {
|
|
294
|
+
try { process.kill(childProcess.pid, "SIGCONT"); } catch { /* ignore */ }
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
return { promise, cancel, pause, resume };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Play a sound and wait for it to finish (legacy — no cancel support).
|
|
303
|
+
*/
|
|
304
|
+
export function playSoundSync(filePath) {
|
|
305
|
+
return playSoundWithCancel(filePath).promise;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Process a sound file with ffmpeg: strip leading silence, clamp to MAX_PLAY_SECONDS,
|
|
310
|
+
* and fade out over the last FADE_SECONDS. Returns the path to the processed WAV file.
|
|
311
|
+
* If ffmpeg is not available or the file is already short enough, returns the original path.
|
|
312
|
+
*/
|
|
313
|
+
export async function processSound(filePath) {
|
|
314
|
+
const absPath = resolve(filePath);
|
|
315
|
+
|
|
316
|
+
// First check duration — skip processing if already short
|
|
317
|
+
const duration = await getWavDuration(absPath);
|
|
318
|
+
if (duration != null && duration <= MAX_PLAY_SECONDS) {
|
|
319
|
+
return absPath; // Already short enough, no processing needed
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Build a deterministic output path based on input file hash
|
|
323
|
+
const hash = createHash("md5").update(absPath).digest("hex").slice(0, 12);
|
|
324
|
+
const outDir = join(tmpdir(), "klaudio-processed");
|
|
325
|
+
const outName = `${basename(absPath, extname(absPath))}_${hash}.wav`;
|
|
326
|
+
const outPath = join(outDir, outName);
|
|
327
|
+
|
|
328
|
+
// Check if already processed
|
|
329
|
+
try {
|
|
330
|
+
await stat(outPath);
|
|
331
|
+
return outPath; // Already exists
|
|
332
|
+
} catch { /* needs processing */ }
|
|
333
|
+
|
|
334
|
+
await mkdir(outDir, { recursive: true });
|
|
335
|
+
|
|
336
|
+
// Build ffmpeg filter chain: silence strip → fade out → clamp duration
|
|
337
|
+
const fadeStart = MAX_PLAY_SECONDS - FADE_SECONDS;
|
|
338
|
+
const filters = [
|
|
339
|
+
"silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB",
|
|
340
|
+
`afade=t=out:st=${fadeStart}:d=${FADE_SECONDS}`,
|
|
341
|
+
].join(",");
|
|
342
|
+
|
|
343
|
+
return new Promise((res) => {
|
|
344
|
+
execFile(
|
|
345
|
+
"ffmpeg",
|
|
346
|
+
[
|
|
347
|
+
"-y", "-i", absPath,
|
|
348
|
+
"-af", filters,
|
|
349
|
+
"-t", String(MAX_PLAY_SECONDS),
|
|
350
|
+
"-ar", "44100", "-ac", "2",
|
|
351
|
+
outPath,
|
|
352
|
+
],
|
|
353
|
+
{ windowsHide: true, timeout: 30000 },
|
|
354
|
+
(err) => {
|
|
355
|
+
if (err) {
|
|
356
|
+
// ffmpeg not available or failed — return original
|
|
357
|
+
res(absPath);
|
|
358
|
+
} else {
|
|
359
|
+
res(outPath);
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Handle the "play" subcommand: play a sound file and optionally speak a TTS summary.
|
|
368
|
+
* Reads hook JSON from stdin to get last_assistant_message for TTS.
|
|
369
|
+
*/
|
|
370
|
+
export async function handlePlayCommand(args) {
|
|
371
|
+
const soundFile = args.find((a) => !a.startsWith("-"));
|
|
372
|
+
const tts = args.includes("--tts");
|
|
373
|
+
|
|
374
|
+
// Read stdin (hook JSON) non-blocking
|
|
375
|
+
let hookData = {};
|
|
376
|
+
try {
|
|
377
|
+
const chunks = [];
|
|
378
|
+
process.stdin.setEncoding("utf-8");
|
|
379
|
+
// Read whatever is available with a short timeout
|
|
380
|
+
const stdinData = await new Promise((res) => {
|
|
381
|
+
const timer = setTimeout(() => { process.stdin.pause(); res(chunks.join("")); }, 500);
|
|
382
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
383
|
+
process.stdin.on("end", () => { clearTimeout(timer); res(chunks.join("")); });
|
|
384
|
+
process.stdin.resume();
|
|
385
|
+
});
|
|
386
|
+
if (stdinData.trim()) hookData = JSON.parse(stdinData);
|
|
387
|
+
} catch { /* no stdin or invalid JSON */ }
|
|
388
|
+
|
|
389
|
+
const notify = args.includes("--notify");
|
|
390
|
+
|
|
391
|
+
// Send system notification (detached, never blocks)
|
|
392
|
+
if (notify) {
|
|
393
|
+
const project = hookData.cwd ? hookData.cwd.replace(/\\/g, "/").split("/").pop() : null;
|
|
394
|
+
const notifTitle = project || "klaudio";
|
|
395
|
+
let notifBody = "Task complete";
|
|
396
|
+
if (hookData.last_assistant_message) {
|
|
397
|
+
// Extract first sentence for the notification body
|
|
398
|
+
const plain = hookData.last_assistant_message
|
|
399
|
+
.replace(/```[\s\S]*?```/g, "").replace(/`([^`]+)`/g, "$1")
|
|
400
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1")
|
|
401
|
+
.replace(/#{1,6}\s+/g, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
402
|
+
.replace(/\([^)]*\)/g, "").replace(/\n+/g, " ").trim();
|
|
403
|
+
const first = plain.match(/^[^.!?]*[.!?]/)?.[0] || plain.slice(0, 120);
|
|
404
|
+
notifBody = first.trim();
|
|
405
|
+
} else if (hookData.message) {
|
|
406
|
+
notifBody = hookData.message.slice(0, 120);
|
|
407
|
+
}
|
|
408
|
+
import("./notify.js").then(({ sendNotification }) =>
|
|
409
|
+
sendNotification(notifTitle, notifBody)
|
|
410
|
+
).catch(() => {});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Play sound (fire and forget, don't wait)
|
|
414
|
+
const soundPromise = soundFile
|
|
415
|
+
? playSoundWithCancel(soundFile).promise.catch(() => {})
|
|
416
|
+
: Promise.resolve();
|
|
417
|
+
|
|
418
|
+
// TTS: speak first 1-2 sentences of last_assistant_message
|
|
419
|
+
if (tts && hookData.last_assistant_message) {
|
|
420
|
+
// Strip markdown syntax and extract first sentence
|
|
421
|
+
const msg = hookData.last_assistant_message
|
|
422
|
+
.replace(/```[\s\S]*?```/g, "") // remove code blocks
|
|
423
|
+
.replace(/`([^`]+)`/g, "$1") // inline code -> text
|
|
424
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1") // **bold** -> text
|
|
425
|
+
.replace(/\*([^*]+)\*/g, "$1") // *italic* -> text
|
|
426
|
+
.replace(/__([^_]+)__/g, "$1") // __bold__ -> text
|
|
427
|
+
.replace(/_([^_]+)_/g, "$1") // _italic_ -> text
|
|
428
|
+
.replace(/#{1,6}\s+/g, "") // headings
|
|
429
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // [links](url) -> text
|
|
430
|
+
.replace(/\([^)]*\)/g, "") // remove parenthesised content
|
|
431
|
+
.replace(/^\s*[-*+]\s+(.*)/gm, "... $1.") // list bullets -> paused items
|
|
432
|
+
.replace(/^\s*\d+\.\s+(.*)/gm, "... $1.") // numbered lists -> paused items
|
|
433
|
+
.replace(/\n+/g, " ") // newlines -> spaces
|
|
434
|
+
.trim();
|
|
435
|
+
// Build summary: include sentences up to ~25 words max.
|
|
436
|
+
// Short next sentences (<4 chars, e.g. version numbers) are always included.
|
|
437
|
+
const MAX_WORDS = 25;
|
|
438
|
+
// Split on sentence-ending punctuation, but not periods between digits (0.8.4)
|
|
439
|
+
// or inside filenames (auth.js). A period is "sentence-ending" only if followed
|
|
440
|
+
// by a space+letter, end-of-string, or another sentence-end mark.
|
|
441
|
+
const sentences = msg.match(/(?:[^.!?]|\.(?=\d|\w{1,5}\b))*[.!?]+/g);
|
|
442
|
+
let summary;
|
|
443
|
+
if (!sentences) {
|
|
444
|
+
summary = msg.split(/\s+/).slice(0, MAX_WORDS).join(" ");
|
|
445
|
+
} else {
|
|
446
|
+
summary = sentences[0].trim();
|
|
447
|
+
for (let i = 1; i < sentences.length; i++) {
|
|
448
|
+
const next = sentences[i].trim();
|
|
449
|
+
const wordsSoFar = summary.split(/\s+/).length;
|
|
450
|
+
const nextWords = next.split(/\s+/).length;
|
|
451
|
+
// Always include tiny fragments (version numbers, short confirmations)
|
|
452
|
+
if (next.length < 4) {
|
|
453
|
+
summary += " " + next;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
// Include next sentence if we're still under the word limit
|
|
457
|
+
if (wordsSoFar + nextWords <= MAX_WORDS) {
|
|
458
|
+
summary += " ... " + next;
|
|
459
|
+
} else {
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// Collapse repeated ellipsis/dots and ensure pauses between sentences
|
|
465
|
+
summary = summary.replace(/\.{2,}/g, "...").replace(/\s{2,}/g, " ");
|
|
466
|
+
// Prefix with project folder name if available
|
|
467
|
+
const project = hookData.cwd ? hookData.cwd.replace(/\\/g, "/").split("/").pop() : null;
|
|
468
|
+
const spoken = project ? `${project}: ${summary}` : summary;
|
|
469
|
+
await soundPromise;
|
|
470
|
+
const { speak } = await import("./tts.js");
|
|
471
|
+
const voice = args.find((a) => a.startsWith("--voice="))?.slice(8)
|
|
472
|
+
|| args[args.indexOf("--voice") + 1];
|
|
473
|
+
await speak(spoken, { voice });
|
|
474
|
+
} else {
|
|
475
|
+
await soundPromise;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Generate the shell command string for use in Claude Code hooks.
|
|
481
|
+
*/
|
|
482
|
+
export function getHookPlayCommand(soundFilePath, { tts = false, voice, notify = true } = {}) {
|
|
483
|
+
const normalized = soundFilePath.replace(/\\/g, "/");
|
|
484
|
+
const ttsFlag = tts ? " --tts" : "";
|
|
485
|
+
const voiceFlag = tts && voice ? ` --voice ${voice}` : "";
|
|
486
|
+
const notifyFlag = notify ? " --notify" : "";
|
|
487
|
+
return `npx klaudio play "${normalized}"${ttsFlag}${voiceFlag}${notifyFlag}`;
|
|
488
|
+
}
|