klaudio 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/README.md +65 -0
- package/bin/cli.js +13 -0
- package/package.json +40 -0
- package/sounds/minimal-zen/notification.wav +0 -0
- package/sounds/minimal-zen/stop.wav +0 -0
- package/sounds/retro-8bit/notification.wav +0 -0
- package/sounds/retro-8bit/stop.wav +0 -0
- package/sounds/sci-fi-terminal/notification.wav +0 -0
- package/sounds/sci-fi-terminal/stop.wav +0 -0
- package/sounds/victory-fanfare/notification.wav +0 -0
- package/sounds/victory-fanfare/stop.wav +0 -0
- package/src/cache.js +262 -0
- package/src/cli.js +999 -0
- package/src/extractor.js +203 -0
- package/src/installer.js +128 -0
- package/src/player.js +359 -0
- package/src/presets.js +75 -0
- package/src/scanner.js +370 -0
package/src/extractor.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readdir, mkdir, stat, chmod } from "node:fs/promises";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { join, extname, basename, resolve as resolvePath } from "node:path";
|
|
5
|
+
import { platform, homedir, tmpdir } from "node:os";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
|
|
8
|
+
const TOOLS_DIR = join(homedir(), ".klonk", "tools");
|
|
9
|
+
|
|
10
|
+
// Packed audio formats that vgmstream-cli can convert to WAV
|
|
11
|
+
const PACKED_EXTENSIONS = new Set([".wem", ".bnk", ".bank", ".fsb", ".pck"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if a file is a packed audio format we can extract.
|
|
15
|
+
*/
|
|
16
|
+
export function isPackedAudio(filePath) {
|
|
17
|
+
return PACKED_EXTENSIONS.has(extname(filePath).toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if a game directory has extractable packed audio.
|
|
22
|
+
*/
|
|
23
|
+
export async function hasPackedAudio(gamePath) {
|
|
24
|
+
const formats = { wem: 0, bnk: 0, bank: 0, fsb: 0, pck: 0 };
|
|
25
|
+
await scanForPackedAudio(gamePath, formats, 0);
|
|
26
|
+
return {
|
|
27
|
+
total: Object.values(formats).reduce((a, b) => a + b, 0),
|
|
28
|
+
formats,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function scanForPackedAudio(dir, formats, depth) {
|
|
33
|
+
if (depth > 5) return;
|
|
34
|
+
try {
|
|
35
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
await scanForPackedAudio(join(dir, entry.name), formats, depth + 1);
|
|
39
|
+
} else if (entry.isFile()) {
|
|
40
|
+
const ext = extname(entry.name).toLowerCase().slice(1);
|
|
41
|
+
if (ext in formats) formats[ext]++;
|
|
42
|
+
}
|
|
43
|
+
// Stop early if we found enough
|
|
44
|
+
if (Object.values(formats).reduce((a, b) => a + b, 0) > 100) return;
|
|
45
|
+
}
|
|
46
|
+
} catch { /* skip */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get the path to vgmstream-cli, downloading it if needed.
|
|
51
|
+
*/
|
|
52
|
+
export async function getVgmstreamPath(onProgress) {
|
|
53
|
+
const os = platform();
|
|
54
|
+
const exeName = os === "win32" ? "vgmstream-cli.exe" : "vgmstream-cli";
|
|
55
|
+
const toolPath = join(TOOLS_DIR, exeName);
|
|
56
|
+
|
|
57
|
+
// Check if already downloaded
|
|
58
|
+
try {
|
|
59
|
+
await stat(toolPath);
|
|
60
|
+
return toolPath;
|
|
61
|
+
} catch { /* not found, need to download */ }
|
|
62
|
+
|
|
63
|
+
if (onProgress) onProgress("Downloading vgmstream-cli...");
|
|
64
|
+
|
|
65
|
+
await mkdir(TOOLS_DIR, { recursive: true });
|
|
66
|
+
|
|
67
|
+
// Download the appropriate release
|
|
68
|
+
const releaseUrl = os === "win32"
|
|
69
|
+
? "https://github.com/vgmstream/vgmstream-releases/releases/download/nightly/vgmstream-win64.zip"
|
|
70
|
+
: os === "darwin"
|
|
71
|
+
? "https://github.com/vgmstream/vgmstream-releases/releases/download/nightly/vgmstream-macos.zip"
|
|
72
|
+
: "https://github.com/vgmstream/vgmstream-releases/releases/download/nightly/vgmstream-linux.zip";
|
|
73
|
+
|
|
74
|
+
const zipPath = join(tmpdir(), "vgmstream.zip");
|
|
75
|
+
|
|
76
|
+
// Download using Node.js fetch
|
|
77
|
+
const response = await fetch(releaseUrl, { redirect: "follow" });
|
|
78
|
+
if (!response.ok) throw new Error(`Failed to download vgmstream: ${response.status}`);
|
|
79
|
+
|
|
80
|
+
const fileStream = createWriteStream(zipPath);
|
|
81
|
+
await pipeline(response.body, fileStream);
|
|
82
|
+
|
|
83
|
+
if (onProgress) onProgress("Extracting vgmstream-cli...");
|
|
84
|
+
|
|
85
|
+
// Extract using tar (handles zip on modern systems) or PowerShell on Windows
|
|
86
|
+
if (os === "win32") {
|
|
87
|
+
await new Promise((resolve, reject) => {
|
|
88
|
+
execFile("powershell.exe", [
|
|
89
|
+
"-NoProfile", "-Command",
|
|
90
|
+
`Expand-Archive -Path '${zipPath}' -DestinationPath '${TOOLS_DIR}' -Force`,
|
|
91
|
+
], { windowsHide: true }, (err) => {
|
|
92
|
+
if (err) reject(err);
|
|
93
|
+
else resolve();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
} else {
|
|
97
|
+
await new Promise((resolve, reject) => {
|
|
98
|
+
execFile("unzip", ["-o", zipPath, "-d", TOOLS_DIR], (err) => {
|
|
99
|
+
if (err) reject(err);
|
|
100
|
+
else resolve();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
// Make executable
|
|
104
|
+
try { await chmod(toolPath, 0o755); } catch { /* ignore */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Verify it exists
|
|
108
|
+
await stat(toolPath);
|
|
109
|
+
return toolPath;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Find all extractable audio files in a game directory.
|
|
114
|
+
*/
|
|
115
|
+
export async function findPackedAudioFiles(gamePath, maxFiles = 50) {
|
|
116
|
+
const results = [];
|
|
117
|
+
|
|
118
|
+
async function scan(dir, depth = 0) {
|
|
119
|
+
if (depth > 5 || results.length >= maxFiles) return;
|
|
120
|
+
try {
|
|
121
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (results.length >= maxFiles) break;
|
|
124
|
+
const fullPath = join(dir, entry.name);
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
const lower = entry.name.toLowerCase();
|
|
127
|
+
if (["__pycache__", "node_modules", ".git"].some(s => lower.includes(s))) continue;
|
|
128
|
+
await scan(fullPath, depth + 1);
|
|
129
|
+
} else if (entry.isFile()) {
|
|
130
|
+
const ext = extname(entry.name).toLowerCase();
|
|
131
|
+
// Formats vgmstream-cli can convert directly
|
|
132
|
+
// (.bnk needs bnkextr preprocessing — skip for now)
|
|
133
|
+
if (ext === ".wem" || ext === ".fsb" || ext === ".bank") {
|
|
134
|
+
results.push({ path: fullPath, name: entry.name, dir });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch { /* skip */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await scan(gamePath);
|
|
142
|
+
return results;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Extract/convert a packed audio file to WAV using vgmstream-cli.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} inputPath - Path to .wem/.bnk/.bank/.fsb file
|
|
149
|
+
* @param {string} outputDir - Directory to write WAV files to
|
|
150
|
+
* @param {string} vgmstreamPath - Path to vgmstream-cli binary
|
|
151
|
+
* @returns {string[]} Array of output WAV file paths
|
|
152
|
+
*/
|
|
153
|
+
export async function extractToWav(inputPath, outputDir, vgmstreamPath) {
|
|
154
|
+
await mkdir(outputDir, { recursive: true });
|
|
155
|
+
|
|
156
|
+
// Resolve to absolute OS-native paths (critical on Windows where
|
|
157
|
+
// MSYS uses forward slashes but native exes need backslashes)
|
|
158
|
+
const absInput = resolvePath(inputPath);
|
|
159
|
+
const ext = extname(absInput).toLowerCase();
|
|
160
|
+
const baseName = basename(absInput, ext);
|
|
161
|
+
const outputPath = resolvePath(join(outputDir, `${baseName}.wav`));
|
|
162
|
+
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
// For .bnk files, vgmstream can extract subsongs
|
|
165
|
+
// First try with -S flag to get subsong count
|
|
166
|
+
execFile(vgmstreamPath, ["-m", absInput], { windowsHide: true, timeout: 10000 }, (err, stdout) => {
|
|
167
|
+
if (err) {
|
|
168
|
+
// Single file conversion
|
|
169
|
+
execFile(vgmstreamPath, ["-o", outputPath, absInput], { windowsHide: true, timeout: 30000 }, (err2) => {
|
|
170
|
+
if (err2) reject(new Error(`Failed to convert ${basename(absInput)}: ${err2.message}`));
|
|
171
|
+
else resolve([outputPath]);
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Check if it has multiple subsongs
|
|
177
|
+
const subsongMatch = stdout.match(/stream count:\s*(\d+)/i) || stdout.match(/subsong count:\s*(\d+)/i);
|
|
178
|
+
const subsongCount = subsongMatch ? parseInt(subsongMatch[1]) : 1;
|
|
179
|
+
|
|
180
|
+
if (subsongCount <= 1) {
|
|
181
|
+
// Single conversion
|
|
182
|
+
execFile(vgmstreamPath, ["-o", outputPath, absInput], { windowsHide: true, timeout: 30000 }, (err2) => {
|
|
183
|
+
if (err2) reject(new Error(`Failed to convert ${basename(absInput)}: ${err2.message}`));
|
|
184
|
+
else resolve([outputPath]);
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
// Extract each subsong (up to 20)
|
|
188
|
+
const count = Math.min(subsongCount, 20);
|
|
189
|
+
const outputs = [];
|
|
190
|
+
let done = 0;
|
|
191
|
+
|
|
192
|
+
for (let i = 1; i <= count; i++) {
|
|
193
|
+
const subOutput = join(outputDir, `${baseName}_${String(i).padStart(3, "0")}.wav`);
|
|
194
|
+
execFile(vgmstreamPath, ["-o", resolvePath(subOutput), "-s", String(i), absInput], { windowsHide: true, timeout: 30000 }, (err2) => {
|
|
195
|
+
if (!err2) outputs.push(subOutput);
|
|
196
|
+
done++;
|
|
197
|
+
if (done === count) resolve(outputs);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|
package/src/installer.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
|
|
2
|
+
import { join, basename, extname } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { getHookPlayCommand, processSound } from "./player.js";
|
|
5
|
+
import { EVENTS } from "./presets.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Get the target directory based on install scope.
|
|
9
|
+
*/
|
|
10
|
+
function getTargetDir(scope) {
|
|
11
|
+
if (scope === "global") {
|
|
12
|
+
return join(homedir(), ".claude");
|
|
13
|
+
}
|
|
14
|
+
return join(process.cwd(), ".claude");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Install sounds and configure hooks.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* @param {string} options.scope - "global" or "project"
|
|
22
|
+
* @param {Record<string, string>} options.sounds - Map of event ID -> source sound file path
|
|
23
|
+
*/
|
|
24
|
+
export async function install({ scope, sounds }) {
|
|
25
|
+
const claudeDir = getTargetDir(scope);
|
|
26
|
+
const soundsDir = join(claudeDir, "sounds");
|
|
27
|
+
const settingsFile = join(claudeDir, "settings.json");
|
|
28
|
+
|
|
29
|
+
// Create sounds directory
|
|
30
|
+
await mkdir(soundsDir, { recursive: true });
|
|
31
|
+
|
|
32
|
+
// Process and copy sound files (clamp to 10s with fadeout via ffmpeg)
|
|
33
|
+
const installedSounds = {};
|
|
34
|
+
for (const [eventId, sourcePath] of Object.entries(sounds)) {
|
|
35
|
+
const processedPath = await processSound(sourcePath);
|
|
36
|
+
const srcName = basename(sourcePath, extname(sourcePath));
|
|
37
|
+
const outExt = extname(processedPath) || ".wav";
|
|
38
|
+
const fileName = `${eventId}-${srcName}${outExt}`;
|
|
39
|
+
const destPath = join(soundsDir, fileName);
|
|
40
|
+
await copyFile(processedPath, destPath);
|
|
41
|
+
installedSounds[eventId] = destPath;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Read existing settings
|
|
45
|
+
let settings = {};
|
|
46
|
+
try {
|
|
47
|
+
const existing = await readFile(settingsFile, "utf-8");
|
|
48
|
+
settings = JSON.parse(existing);
|
|
49
|
+
} catch {
|
|
50
|
+
// File doesn't exist or is invalid — start fresh
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Build hooks config
|
|
54
|
+
if (!settings.hooks) {
|
|
55
|
+
settings.hooks = {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const [eventId, soundPath] of Object.entries(installedSounds)) {
|
|
59
|
+
const event = EVENTS[eventId];
|
|
60
|
+
if (!event) continue;
|
|
61
|
+
|
|
62
|
+
const hookEvent = event.hookEvent;
|
|
63
|
+
const playCommand = getHookPlayCommand(soundPath);
|
|
64
|
+
|
|
65
|
+
// Check if there's already a klonk hook for this event
|
|
66
|
+
if (!settings.hooks[hookEvent]) {
|
|
67
|
+
settings.hooks[hookEvent] = [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Remove any existing klonk entries
|
|
71
|
+
settings.hooks[hookEvent] = settings.hooks[hookEvent].filter(
|
|
72
|
+
(entry) => !entry._klonk
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Add our hook
|
|
76
|
+
settings.hooks[hookEvent].push({
|
|
77
|
+
_klonk: true,
|
|
78
|
+
matcher: "",
|
|
79
|
+
hooks: [
|
|
80
|
+
{
|
|
81
|
+
type: "command",
|
|
82
|
+
command: playCommand,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Write settings
|
|
89
|
+
await writeFile(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
soundsDir,
|
|
93
|
+
settingsFile,
|
|
94
|
+
installedSounds,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Uninstall klonk hooks from settings.
|
|
100
|
+
*/
|
|
101
|
+
export async function uninstall(scope) {
|
|
102
|
+
const claudeDir = getTargetDir(scope);
|
|
103
|
+
const settingsFile = join(claudeDir, "settings.json");
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const existing = await readFile(settingsFile, "utf-8");
|
|
107
|
+
const settings = JSON.parse(existing);
|
|
108
|
+
|
|
109
|
+
if (settings.hooks) {
|
|
110
|
+
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
111
|
+
settings.hooks[event] = entries.filter(
|
|
112
|
+
(entry) => !entry._klonk
|
|
113
|
+
);
|
|
114
|
+
if (settings.hooks[event].length === 0) {
|
|
115
|
+
delete settings.hooks[event];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
119
|
+
delete settings.hooks;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await writeFile(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
124
|
+
return true;
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/player.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
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 } = {}) {
|
|
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 MAX_PLAY_SECONDS cut
|
|
26
|
+
const fadeStart = MAX_PLAY_SECONDS - 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(MAX_PLAY_SECONDS));
|
|
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) {
|
|
201
|
+
const absPath = resolve(filePath);
|
|
202
|
+
const strategy = getPlaybackCommand(absPath, { withFade: true });
|
|
203
|
+
let childProcess = null;
|
|
204
|
+
let timer = null;
|
|
205
|
+
let cancelled = false;
|
|
206
|
+
|
|
207
|
+
function killChild() {
|
|
208
|
+
if (childProcess && !childProcess.killed) {
|
|
209
|
+
try {
|
|
210
|
+
// On Windows, spawned processes need taskkill for the process tree
|
|
211
|
+
if (platform() === "win32") {
|
|
212
|
+
spawn("taskkill", ["/pid", String(childProcess.pid), "/f", "/t"], {
|
|
213
|
+
stdio: "ignore", windowsHide: true,
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
childProcess.kill("SIGTERM");
|
|
217
|
+
}
|
|
218
|
+
} catch { /* ignore */ }
|
|
219
|
+
}
|
|
220
|
+
if (timer) clearTimeout(timer);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const cancel = () => {
|
|
224
|
+
cancelled = true;
|
|
225
|
+
killChild();
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const promise = new Promise((resolvePromise, reject) => {
|
|
229
|
+
function onDone(err) {
|
|
230
|
+
if (timer) clearTimeout(timer);
|
|
231
|
+
if (cancelled) return resolvePromise(); // cancelled — resolve, don't reject
|
|
232
|
+
if (err) reject(err);
|
|
233
|
+
else resolvePromise();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function startExec(cmd, args) {
|
|
237
|
+
childProcess = execFile(cmd, args, { windowsHide: true, timeout: (MAX_PLAY_SECONDS + 2) * 1000 }, (err) => {
|
|
238
|
+
if (err && strategy.fallback && !cancelled) {
|
|
239
|
+
if (strategy.fallback === "powershell") {
|
|
240
|
+
childProcess = execFile(
|
|
241
|
+
"powershell.exe",
|
|
242
|
+
["-NoProfile", "-Command", buildPsCommand(absPath, MAX_PLAY_SECONDS)],
|
|
243
|
+
{ windowsHide: true, timeout: (MAX_PLAY_SECONDS + 2) * 1000 },
|
|
244
|
+
(psErr) => onDone(psErr)
|
|
245
|
+
);
|
|
246
|
+
} else if (strategy.fallback === "afplay") {
|
|
247
|
+
// macOS: ffplay not available, fall back to afplay (no fade)
|
|
248
|
+
childProcess = execFile("afplay", [absPath], { timeout: (MAX_PLAY_SECONDS + 2) * 1000 }, (afErr) => onDone(afErr));
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
onDone(err);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Set a hard timeout to kill after MAX_PLAY_SECONDS
|
|
256
|
+
timer = setTimeout(() => {
|
|
257
|
+
killChild();
|
|
258
|
+
resolvePromise();
|
|
259
|
+
}, MAX_PLAY_SECONDS * 1000);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (strategy.type === "exec") {
|
|
263
|
+
startExec(strategy.cmd, strategy.args);
|
|
264
|
+
} else if (strategy.type === "powershell") {
|
|
265
|
+
childProcess = execFile(
|
|
266
|
+
"powershell.exe",
|
|
267
|
+
["-NoProfile", "-Command", buildPsCommand(absPath, MAX_PLAY_SECONDS)],
|
|
268
|
+
{ windowsHide: true, timeout: (MAX_PLAY_SECONDS + 2) * 1000 },
|
|
269
|
+
(err) => onDone(err)
|
|
270
|
+
);
|
|
271
|
+
timer = setTimeout(() => {
|
|
272
|
+
killChild();
|
|
273
|
+
resolvePromise();
|
|
274
|
+
}, MAX_PLAY_SECONDS * 1000);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return { promise, cancel };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Play a sound and wait for it to finish (legacy — no cancel support).
|
|
283
|
+
*/
|
|
284
|
+
export function playSoundSync(filePath) {
|
|
285
|
+
return playSoundWithCancel(filePath).promise;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Process a sound file with ffmpeg: strip leading silence, clamp to MAX_PLAY_SECONDS,
|
|
290
|
+
* and fade out over the last FADE_SECONDS. Returns the path to the processed WAV file.
|
|
291
|
+
* If ffmpeg is not available or the file is already short enough, returns the original path.
|
|
292
|
+
*/
|
|
293
|
+
export async function processSound(filePath) {
|
|
294
|
+
const absPath = resolve(filePath);
|
|
295
|
+
|
|
296
|
+
// First check duration — skip processing if already short
|
|
297
|
+
const duration = await getWavDuration(absPath);
|
|
298
|
+
if (duration != null && duration <= MAX_PLAY_SECONDS) {
|
|
299
|
+
return absPath; // Already short enough, no processing needed
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Build a deterministic output path based on input file hash
|
|
303
|
+
const hash = createHash("md5").update(absPath).digest("hex").slice(0, 12);
|
|
304
|
+
const outDir = join(tmpdir(), "klonk-processed");
|
|
305
|
+
const outName = `${basename(absPath, extname(absPath))}_${hash}.wav`;
|
|
306
|
+
const outPath = join(outDir, outName);
|
|
307
|
+
|
|
308
|
+
// Check if already processed
|
|
309
|
+
try {
|
|
310
|
+
await stat(outPath);
|
|
311
|
+
return outPath; // Already exists
|
|
312
|
+
} catch { /* needs processing */ }
|
|
313
|
+
|
|
314
|
+
await mkdir(outDir, { recursive: true });
|
|
315
|
+
|
|
316
|
+
// Build ffmpeg filter chain: silence strip → fade out → clamp duration
|
|
317
|
+
const fadeStart = MAX_PLAY_SECONDS - FADE_SECONDS;
|
|
318
|
+
const filters = [
|
|
319
|
+
"silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB",
|
|
320
|
+
`afade=t=out:st=${fadeStart}:d=${FADE_SECONDS}`,
|
|
321
|
+
].join(",");
|
|
322
|
+
|
|
323
|
+
return new Promise((res) => {
|
|
324
|
+
execFile(
|
|
325
|
+
"ffmpeg",
|
|
326
|
+
[
|
|
327
|
+
"-y", "-i", absPath,
|
|
328
|
+
"-af", filters,
|
|
329
|
+
"-t", String(MAX_PLAY_SECONDS),
|
|
330
|
+
"-ar", "44100", "-ac", "2",
|
|
331
|
+
outPath,
|
|
332
|
+
],
|
|
333
|
+
{ windowsHide: true, timeout: 30000 },
|
|
334
|
+
(err) => {
|
|
335
|
+
if (err) {
|
|
336
|
+
// ffmpeg not available or failed — return original
|
|
337
|
+
res(absPath);
|
|
338
|
+
} else {
|
|
339
|
+
res(outPath);
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Generate the shell command string for use in Claude Code hooks.
|
|
348
|
+
*/
|
|
349
|
+
export function getHookPlayCommand(soundFilePath) {
|
|
350
|
+
const normalized = soundFilePath.replace(/\\/g, "/");
|
|
351
|
+
const ext = extname(normalized).toLowerCase();
|
|
352
|
+
const needsFfplay = !MEDIA_PLAYER_FORMATS.has(ext);
|
|
353
|
+
|
|
354
|
+
if (needsFfplay) {
|
|
355
|
+
return `if command -v ffplay &>/dev/null; then ffplay -nodisp -autoexit -loglevel quiet "${normalized}" & elif [[ "$OSTYPE" == "darwin"* ]]; then afplay "${normalized}" & elif [[ "$OSTYPE" == "msys"* ]] || [[ "$OSTYPE" == "cygwin"* ]]; then powershell.exe -NoProfile -Command "Add-Type -AssemblyName PresentationCore; \\$p = New-Object System.Windows.Media.MediaPlayer; \\$p.Open([System.Uri]::new('$(cygpath -w "${normalized}")')); Start-Sleep -Milliseconds 200; \\$p.Play(); Start-Sleep -Seconds 2" & else aplay "${normalized}" & fi`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return `if [[ "$OSTYPE" == "darwin"* ]]; then afplay "${normalized}" & elif [[ "$OSTYPE" == "msys"* ]] || [[ "$OSTYPE" == "cygwin"* ]]; then powershell.exe -NoProfile -Command "Add-Type -AssemblyName PresentationCore; \\$p = New-Object System.Windows.Media.MediaPlayer; \\$p.Open([System.Uri]::new('$(cygpath -w "${normalized}")')); Start-Sleep -Milliseconds 200; \\$p.Play(); Start-Sleep -Seconds 2" & else aplay "${normalized}" & fi`;
|
|
359
|
+
}
|