klaudio 0.6.0 → 0.8.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/bin/cli.js +9 -1
- package/package.json +1 -1
- package/src/cli.js +91 -16
- package/src/installer.js +5 -2
- package/src/player.js +44 -8
- package/src/scanner.js +40 -0
- package/src/tts.js +220 -0
package/bin/cli.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Subcommand: klaudio play <file> [--tts]
|
|
4
|
+
if (process.argv[2] === "play") {
|
|
5
|
+
const { handlePlayCommand } = await import("../src/player.js");
|
|
6
|
+
await handlePlayCommand(process.argv.slice(3));
|
|
7
|
+
process.exit(0);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Default: interactive installer UI
|
|
11
|
+
const { run } = await import("../src/cli.js");
|
|
4
12
|
|
|
5
13
|
run().catch((err) => {
|
|
6
14
|
if (err.name === "ExitPromptError") {
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { render, Box, Text, useInput, useApp } from "ink";
|
|
|
3
3
|
import Spinner from "ink-spinner";
|
|
4
4
|
import { PRESETS, EVENTS } from "./presets.js";
|
|
5
5
|
import { playSoundWithCancel, getWavDuration } from "./player.js";
|
|
6
|
-
import { getAvailableGames } from "./scanner.js";
|
|
6
|
+
import { getAvailableGames, getSystemSounds } from "./scanner.js";
|
|
7
7
|
import { install, uninstall, getExistingSounds } from "./installer.js";
|
|
8
8
|
import { getVgmstreamPath, findPackedAudioFiles, extractToWav } from "./extractor.js";
|
|
9
9
|
import { extractUnityResource } from "./unity.js";
|
|
@@ -39,8 +39,17 @@ const SelectInput = ({ items = [], isFocused = true, initialIndex = 0, indicator
|
|
|
39
39
|
const prevValues = previousItems.current.map((i) => i.value);
|
|
40
40
|
const curValues = items.map((i) => i.value);
|
|
41
41
|
if (prevValues.length !== curValues.length || prevValues.some((v, i) => v !== curValues[i])) {
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Try to keep the currently selected item highlighted
|
|
43
|
+
const prevSelected = previousItems.current[selectedIndex];
|
|
44
|
+
const newIdx = prevSelected ? items.findIndex((i) => i.value === prevSelected.value) : -1;
|
|
45
|
+
if (newIdx >= 0) {
|
|
46
|
+
setSelectedIndex(newIdx);
|
|
47
|
+
setScrollOffset(hasLimit ? Math.max(0, Math.min(newIdx, items.length - limit)) : 0);
|
|
48
|
+
} else {
|
|
49
|
+
// Selected item gone — reset to top
|
|
50
|
+
setScrollOffset(0);
|
|
51
|
+
setSelectedIndex(0);
|
|
52
|
+
}
|
|
44
53
|
}
|
|
45
54
|
previousItems.current = items;
|
|
46
55
|
}, [items]);
|
|
@@ -115,7 +124,7 @@ const NavHint = ({ back = true, extra = "" }) =>
|
|
|
115
124
|
);
|
|
116
125
|
|
|
117
126
|
// ── Screen: Scope ───────────────────────────────────────────────
|
|
118
|
-
const ScopeScreen = ({ onNext, onMusic }) => {
|
|
127
|
+
const ScopeScreen = ({ onNext, onMusic, tts, onToggleTts }) => {
|
|
119
128
|
const items = [
|
|
120
129
|
{ label: "Global — Claude Code + Copilot (all projects)", value: "global" },
|
|
121
130
|
{ label: "This project — Claude Code + Copilot (this project only)", value: "project" },
|
|
@@ -130,6 +139,8 @@ const ScopeScreen = ({ onNext, onMusic }) => {
|
|
|
130
139
|
setSel((i) => Math.max(0, i - 1));
|
|
131
140
|
} else if (input === "j" || key.downArrow) {
|
|
132
141
|
setSel((i) => Math.min(items.length - 1, i + 1));
|
|
142
|
+
} else if (input === "t") {
|
|
143
|
+
onToggleTts();
|
|
133
144
|
} else if (key.return) {
|
|
134
145
|
const v = items[sel].value;
|
|
135
146
|
if (v === "_music") onMusic();
|
|
@@ -148,26 +159,47 @@ const ScopeScreen = ({ onNext, onMusic }) => {
|
|
|
148
159
|
),
|
|
149
160
|
)),
|
|
150
161
|
),
|
|
162
|
+
h(Box, { marginTop: 1, marginLeft: 4 },
|
|
163
|
+
h(Text, { color: tts ? "green" : "gray" },
|
|
164
|
+
tts ? "🗣 Voice summary: ON" : "🗣 Voice summary: OFF",
|
|
165
|
+
),
|
|
166
|
+
h(Text, { dimColor: true }, " (t to toggle)"),
|
|
167
|
+
),
|
|
151
168
|
);
|
|
152
169
|
};
|
|
153
170
|
|
|
154
171
|
// ── Screen: Preset ──────────────────────────────────────────────
|
|
155
172
|
const PresetScreen = ({ onNext, onBack }) => {
|
|
156
|
-
useInput((_, key) => { if (key.escape) onBack(); });
|
|
157
|
-
|
|
158
173
|
const items = [
|
|
159
174
|
...Object.entries(PRESETS).map(([id, p]) => ({
|
|
160
175
|
label: `${p.icon} ${p.name} — ${p.description}`,
|
|
161
176
|
value: id,
|
|
162
177
|
})),
|
|
163
|
-
|
|
178
|
+
// separator before these
|
|
179
|
+
{ label: "🔔 System sounds — use built-in OS notification sounds", value: "_system" },
|
|
180
|
+
{ label: "🕹️ Scan your games library — find sounds from Steam & Epic Games", value: "_scan" },
|
|
164
181
|
{ label: "📁 Custom files — provide your own sound files", value: "_custom" },
|
|
165
182
|
];
|
|
183
|
+
const GAP_AT = Object.keys(PRESETS).length; // separator before non-preset options
|
|
184
|
+
const [sel, setSel] = useState(0);
|
|
185
|
+
|
|
186
|
+
useInput((input, key) => {
|
|
187
|
+
if (key.escape) onBack();
|
|
188
|
+
else if (input === "k" || key.upArrow) setSel((i) => Math.max(0, i - 1));
|
|
189
|
+
else if (input === "j" || key.downArrow) setSel((i) => Math.min(items.length - 1, i + 1));
|
|
190
|
+
else if (key.return) onNext(items[sel].value);
|
|
191
|
+
});
|
|
166
192
|
|
|
167
193
|
return h(Box, { flexDirection: "column" },
|
|
168
194
|
h(Text, { bold: true }, " Choose a sound preset:"),
|
|
169
|
-
h(Box, { marginLeft: 2 },
|
|
170
|
-
|
|
195
|
+
h(Box, { flexDirection: "column", marginLeft: 2 },
|
|
196
|
+
...items.map((item, i) => h(React.Fragment, { key: item.value },
|
|
197
|
+
i === GAP_AT ? h(Text, { dimColor: true }, "\n ...or pick your own") : null,
|
|
198
|
+
h(Box, null,
|
|
199
|
+
h(Indicator, { isSelected: i === sel }),
|
|
200
|
+
h(Item, { isSelected: i === sel, label: item.label }),
|
|
201
|
+
),
|
|
202
|
+
)),
|
|
171
203
|
),
|
|
172
204
|
h(NavHint, { back: true }),
|
|
173
205
|
);
|
|
@@ -600,6 +632,16 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
600
632
|
return () => clearInterval(interval);
|
|
601
633
|
}, [playing]);
|
|
602
634
|
|
|
635
|
+
// Parse duration filter: "10s", "<10s", "< 10s", ">5s", "> 5s", "<=10s", ">=5s"
|
|
636
|
+
// (must be before early returns to satisfy React hook rules)
|
|
637
|
+
const durationFilter = useMemo(() => {
|
|
638
|
+
const m = filter.match(/^\s*(<|>|<=|>=)?\s*(\d+(?:\.\d+)?)\s*s\s*$/);
|
|
639
|
+
if (!m) return null;
|
|
640
|
+
const op = m[1] || "<=";
|
|
641
|
+
const val = parseFloat(m[2]);
|
|
642
|
+
return { op, val };
|
|
643
|
+
}, [filter]);
|
|
644
|
+
|
|
603
645
|
const eventId = eventIds[currentEvent];
|
|
604
646
|
const eventInfo = EVENTS[eventId];
|
|
605
647
|
const stepLabel = `(${currentEvent + 1}/${eventIds.length})`;
|
|
@@ -757,6 +799,7 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
757
799
|
|
|
758
800
|
// Phase 1: Browse and pick files (auto-preview plays on highlight)
|
|
759
801
|
const filterLower = filter.toLowerCase();
|
|
802
|
+
|
|
760
803
|
const allFileItems = categoryFiles.map((f) => {
|
|
761
804
|
const dur = fileDurations[f.path];
|
|
762
805
|
const durStr = dur != null ? ` (${dur}s${dur > MAX_PLAY_SECONDS ? `, preview ${MAX_PLAY_SECONDS}s` : ""})` : "";
|
|
@@ -768,11 +811,22 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
768
811
|
label: `${catTag}${name}${durStr}`,
|
|
769
812
|
usedTag: usedFor ? ` ← ${usedFor.join(", ")}` : null,
|
|
770
813
|
value: f.path,
|
|
814
|
+
_dur: dur,
|
|
771
815
|
};
|
|
772
816
|
});
|
|
773
817
|
|
|
774
818
|
const filteredFiles = filter
|
|
775
|
-
?
|
|
819
|
+
? durationFilter
|
|
820
|
+
? allFileItems.filter((i) => {
|
|
821
|
+
if (i._dur == null) return false;
|
|
822
|
+
const { op, val } = durationFilter;
|
|
823
|
+
if (op === "<") return i._dur < val;
|
|
824
|
+
if (op === ">") return i._dur > val;
|
|
825
|
+
if (op === "<=") return i._dur <= val;
|
|
826
|
+
if (op === ">=") return i._dur >= val;
|
|
827
|
+
return true;
|
|
828
|
+
})
|
|
829
|
+
: allFileItems.filter((i) => i.label.toLowerCase().includes(filterLower))
|
|
776
830
|
: allFileItems;
|
|
777
831
|
|
|
778
832
|
const fileItems = [
|
|
@@ -797,12 +851,12 @@ const GameSoundsScreen = ({ game, sounds, onSelectSound, onDone, onBack }) => {
|
|
|
797
851
|
),
|
|
798
852
|
filter
|
|
799
853
|
? h(Box, { marginLeft: 4 },
|
|
800
|
-
h(Text, { color: "yellow" }, "Filter: "),
|
|
854
|
+
h(Text, { color: "yellow" }, durationFilter ? "Duration: " : "Filter: "),
|
|
801
855
|
h(Text, { bold: true }, filter),
|
|
802
856
|
h(Text, { dimColor: true }, ` (${filteredFiles.length} match${filteredFiles.length !== 1 ? "es" : ""})`),
|
|
803
857
|
)
|
|
804
858
|
: categoryFiles.length > 15
|
|
805
|
-
? h(Text, { dimColor: true, marginLeft: 4 }, "Type to filter...")
|
|
859
|
+
? h(Text, { dimColor: true, marginLeft: 4 }, "Type to filter... (e.g. <10s, >5s)")
|
|
806
860
|
: null,
|
|
807
861
|
fileItems.length > 0
|
|
808
862
|
? h(Box, { marginLeft: 2 },
|
|
@@ -1248,8 +1302,11 @@ const ExtractingScreen = ({ game, onDone, onBack }) => {
|
|
|
1248
1302
|
};
|
|
1249
1303
|
|
|
1250
1304
|
// ── Screen: Confirm ─────────────────────────────────────────────
|
|
1251
|
-
const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
|
|
1252
|
-
useInput((
|
|
1305
|
+
const ConfirmScreen = ({ scope, sounds, tts, onToggleTts, onConfirm, onBack }) => {
|
|
1306
|
+
useInput((input, key) => {
|
|
1307
|
+
if (key.escape) onBack();
|
|
1308
|
+
else if (input === "t") onToggleTts();
|
|
1309
|
+
});
|
|
1253
1310
|
|
|
1254
1311
|
const items = [
|
|
1255
1312
|
{ label: "✓ Yes, install!", value: "yes" },
|
|
@@ -1267,6 +1324,12 @@ const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
|
|
|
1267
1324
|
`${EVENTS[eid].name} → ${basename(path)}`
|
|
1268
1325
|
)
|
|
1269
1326
|
),
|
|
1327
|
+
h(Box, { marginLeft: 4, marginTop: 1 },
|
|
1328
|
+
h(Text, { color: tts ? "green" : "gray" },
|
|
1329
|
+
tts ? "🗣 Voice summary: ON" : "🗣 Voice summary: OFF",
|
|
1330
|
+
),
|
|
1331
|
+
h(Text, { dimColor: true }, " (t to toggle — reads a short summary when tasks complete)"),
|
|
1332
|
+
),
|
|
1270
1333
|
),
|
|
1271
1334
|
h(Box, { marginTop: 1, marginLeft: 2 },
|
|
1272
1335
|
h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => {
|
|
@@ -1279,13 +1342,13 @@ const ConfirmScreen = ({ scope, sounds, onConfirm, onBack }) => {
|
|
|
1279
1342
|
};
|
|
1280
1343
|
|
|
1281
1344
|
// ── Screen: Installing ──────────────────────────────────────────
|
|
1282
|
-
const InstallingScreen = ({ scope, sounds, onDone }) => {
|
|
1345
|
+
const InstallingScreen = ({ scope, sounds, tts, onDone }) => {
|
|
1283
1346
|
useEffect(() => {
|
|
1284
1347
|
const validSounds = {};
|
|
1285
1348
|
for (const [eventId, path] of Object.entries(sounds)) {
|
|
1286
1349
|
if (path) validSounds[eventId] = path;
|
|
1287
1350
|
}
|
|
1288
|
-
install({ scope, sounds: validSounds }).then(onDone).catch((err) => {
|
|
1351
|
+
install({ scope, sounds: validSounds, tts }).then(onDone).catch((err) => {
|
|
1289
1352
|
onDone({ error: err.message });
|
|
1290
1353
|
});
|
|
1291
1354
|
}, []);
|
|
@@ -1389,6 +1452,7 @@ const InstallApp = () => {
|
|
|
1389
1452
|
const [sounds, setSounds] = useState({});
|
|
1390
1453
|
const [selectedGame, setSelectedGame] = useState(null);
|
|
1391
1454
|
const [installResult, setInstallResult] = useState(null);
|
|
1455
|
+
const [tts, setTts] = useState(true);
|
|
1392
1456
|
const [musicFiles, setMusicFiles] = useState([]);
|
|
1393
1457
|
const [musicGameName, setMusicGameName] = useState(null);
|
|
1394
1458
|
const [musicShuffle, setMusicShuffle] = useState(false);
|
|
@@ -1402,6 +1466,8 @@ const InstallApp = () => {
|
|
|
1402
1466
|
switch (screen) {
|
|
1403
1467
|
case SCREEN.SCOPE:
|
|
1404
1468
|
return h(ScopeScreen, {
|
|
1469
|
+
tts,
|
|
1470
|
+
onToggleTts: () => setTts((v) => !v),
|
|
1405
1471
|
onNext: (s) => {
|
|
1406
1472
|
setScope(s);
|
|
1407
1473
|
getExistingSounds(s).then((existing) => {
|
|
@@ -1417,6 +1483,12 @@ const InstallApp = () => {
|
|
|
1417
1483
|
onNext: (id) => {
|
|
1418
1484
|
if (id === "_music") {
|
|
1419
1485
|
setScreen(SCREEN.MUSIC_MODE);
|
|
1486
|
+
} else if (id === "_system") {
|
|
1487
|
+
getSystemSounds().then((files) => {
|
|
1488
|
+
const catFiles = categorizeLooseFiles(files);
|
|
1489
|
+
setSelectedGame({ name: "System Sounds", path: "", files: catFiles, fileCount: catFiles.length, hasAudio: catFiles.length > 0 });
|
|
1490
|
+
setScreen(SCREEN.GAME_SOUNDS);
|
|
1491
|
+
});
|
|
1420
1492
|
} else if (id === "_scan") {
|
|
1421
1493
|
setScreen(SCREEN.GAME_PICK);
|
|
1422
1494
|
} else if (id === "_custom") {
|
|
@@ -1506,6 +1578,8 @@ const InstallApp = () => {
|
|
|
1506
1578
|
return h(ConfirmScreen, {
|
|
1507
1579
|
scope,
|
|
1508
1580
|
sounds,
|
|
1581
|
+
tts,
|
|
1582
|
+
onToggleTts: () => setTts((v) => !v),
|
|
1509
1583
|
onConfirm: () => setScreen(SCREEN.INSTALLING),
|
|
1510
1584
|
onBack: () => {
|
|
1511
1585
|
if (selectedGame) setScreen(SCREEN.GAME_SOUNDS);
|
|
@@ -1517,6 +1591,7 @@ const InstallApp = () => {
|
|
|
1517
1591
|
return h(InstallingScreen, {
|
|
1518
1592
|
scope,
|
|
1519
1593
|
sounds,
|
|
1594
|
+
tts,
|
|
1520
1595
|
onDone: (result) => {
|
|
1521
1596
|
setInstallResult(result);
|
|
1522
1597
|
setScreen(SCREEN.DONE);
|
package/src/installer.js
CHANGED
|
@@ -20,8 +20,9 @@ function getTargetDir(scope) {
|
|
|
20
20
|
* @param {object} options
|
|
21
21
|
* @param {string} options.scope - "global" or "project"
|
|
22
22
|
* @param {Record<string, string>} options.sounds - Map of event ID -> source sound file path
|
|
23
|
+
* @param {boolean} [options.tts] - Enable TTS voice summary on task complete
|
|
23
24
|
*/
|
|
24
|
-
export async function install({ scope, sounds }) {
|
|
25
|
+
export async function install({ scope, sounds, tts = false }) {
|
|
25
26
|
const claudeDir = getTargetDir(scope);
|
|
26
27
|
const soundsDir = join(claudeDir, "sounds");
|
|
27
28
|
const settingsFile = join(claudeDir, "settings.json");
|
|
@@ -60,7 +61,9 @@ export async function install({ scope, sounds }) {
|
|
|
60
61
|
if (!event) continue;
|
|
61
62
|
|
|
62
63
|
const hookEvent = event.hookEvent;
|
|
63
|
-
|
|
64
|
+
// Enable TTS only for the "stop" event (task complete)
|
|
65
|
+
const useTts = tts && eventId === "stop";
|
|
66
|
+
const playCommand = getHookPlayCommand(soundPath, { tts: useTts });
|
|
64
67
|
|
|
65
68
|
// Check if there's already a klaudio hook for this event
|
|
66
69
|
if (!settings.hooks[hookEvent]) {
|
package/src/player.js
CHANGED
|
@@ -363,16 +363,52 @@ export async function processSound(filePath) {
|
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
/**
|
|
366
|
-
*
|
|
366
|
+
* Handle the "play" subcommand: play a sound file and optionally speak a TTS summary.
|
|
367
|
+
* Reads hook JSON from stdin to get last_assistant_message for TTS.
|
|
367
368
|
*/
|
|
368
|
-
export function
|
|
369
|
-
const
|
|
370
|
-
const
|
|
371
|
-
const needsFfplay = !MEDIA_PLAYER_FORMATS.has(ext);
|
|
369
|
+
export async function handlePlayCommand(args) {
|
|
370
|
+
const soundFile = args.find((a) => !a.startsWith("-"));
|
|
371
|
+
const tts = args.includes("--tts");
|
|
372
372
|
|
|
373
|
-
|
|
374
|
-
|
|
373
|
+
// Read stdin (hook JSON) non-blocking
|
|
374
|
+
let hookData = {};
|
|
375
|
+
try {
|
|
376
|
+
const chunks = [];
|
|
377
|
+
process.stdin.setEncoding("utf-8");
|
|
378
|
+
// Read whatever is available with a short timeout
|
|
379
|
+
const stdinData = await new Promise((res) => {
|
|
380
|
+
const timer = setTimeout(() => { process.stdin.pause(); res(chunks.join("")); }, 500);
|
|
381
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
382
|
+
process.stdin.on("end", () => { clearTimeout(timer); res(chunks.join("")); });
|
|
383
|
+
process.stdin.resume();
|
|
384
|
+
});
|
|
385
|
+
if (stdinData.trim()) hookData = JSON.parse(stdinData);
|
|
386
|
+
} catch { /* no stdin or invalid JSON */ }
|
|
387
|
+
|
|
388
|
+
// Play sound (fire and forget, don't wait)
|
|
389
|
+
const soundPromise = soundFile
|
|
390
|
+
? playSoundWithCancel(soundFile).promise.catch(() => {})
|
|
391
|
+
: Promise.resolve();
|
|
392
|
+
|
|
393
|
+
// TTS: speak first 1-2 sentences of last_assistant_message
|
|
394
|
+
if (tts && hookData.last_assistant_message) {
|
|
395
|
+
const msg = hookData.last_assistant_message;
|
|
396
|
+
// Extract first sentence only
|
|
397
|
+
const sentences = msg.match(/[^.!?]*[.!?]/g);
|
|
398
|
+
const summary = sentences ? sentences[0].trim() : msg.slice(0, 100);
|
|
399
|
+
await soundPromise;
|
|
400
|
+
const { speak } = await import("./tts.js");
|
|
401
|
+
await speak(summary);
|
|
402
|
+
} else {
|
|
403
|
+
await soundPromise;
|
|
375
404
|
}
|
|
405
|
+
}
|
|
376
406
|
|
|
377
|
-
|
|
407
|
+
/**
|
|
408
|
+
* Generate the shell command string for use in Claude Code hooks.
|
|
409
|
+
*/
|
|
410
|
+
export function getHookPlayCommand(soundFilePath, { tts = false } = {}) {
|
|
411
|
+
const normalized = soundFilePath.replace(/\\/g, "/");
|
|
412
|
+
const ttsFlag = tts ? " --tts" : "";
|
|
413
|
+
return `npx klaudio play "${normalized}"${ttsFlag}`;
|
|
378
414
|
}
|
package/src/scanner.js
CHANGED
|
@@ -379,6 +379,46 @@ export async function scanForGames(onProgress, onGameFound) {
|
|
|
379
379
|
return games;
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Discover system sounds (Windows Media, macOS system sounds, Linux sound themes).
|
|
384
|
+
* Returns an array of { path, name, dir } matching the game file format.
|
|
385
|
+
*/
|
|
386
|
+
export async function getSystemSounds() {
|
|
387
|
+
const os = platform();
|
|
388
|
+
const results = [];
|
|
389
|
+
|
|
390
|
+
const dirs = [];
|
|
391
|
+
if (os === "win32") {
|
|
392
|
+
dirs.push("C:/Windows/Media", "C:/Windows/Media/dm");
|
|
393
|
+
} else if (os === "darwin") {
|
|
394
|
+
dirs.push("/System/Library/Sounds");
|
|
395
|
+
} else {
|
|
396
|
+
// Linux: common sound theme locations
|
|
397
|
+
dirs.push(
|
|
398
|
+
"/usr/share/sounds/freedesktop/stereo",
|
|
399
|
+
"/usr/share/sounds/gnome/default/alerts",
|
|
400
|
+
"/usr/share/sounds/ubuntu/stereo",
|
|
401
|
+
"/usr/share/sounds",
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
for (const dir of dirs) {
|
|
406
|
+
if (!(await dirExists(dir))) continue;
|
|
407
|
+
try {
|
|
408
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
409
|
+
for (const entry of entries) {
|
|
410
|
+
if (!entry.isFile()) continue;
|
|
411
|
+
const ext = extname(entry.name).toLowerCase();
|
|
412
|
+
if (AUDIO_EXTENSIONS.has(ext)) {
|
|
413
|
+
results.push({ path: join(dir, entry.name), name: entry.name, dir });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} catch { /* skip */ }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return results;
|
|
420
|
+
}
|
|
421
|
+
|
|
382
422
|
/**
|
|
383
423
|
* Get a flat list of all found game directories.
|
|
384
424
|
*/
|
package/src/tts.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, stat, rename, chmod } from "node:fs/promises";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { join, basename } from "node:path";
|
|
5
|
+
import { homedir, platform, arch, tmpdir } from "node:os";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
const PIPER_VERSION = "2023.11.14-2";
|
|
10
|
+
const VOICE_NAME = "en_GB-alan-medium";
|
|
11
|
+
const VOICE_SAMPLE_RATE = 22050;
|
|
12
|
+
|
|
13
|
+
const PIPER_DIR = join(homedir(), ".klaudio", "piper");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the piper release asset name for the current platform.
|
|
17
|
+
*/
|
|
18
|
+
function getPiperAssetName() {
|
|
19
|
+
const os = platform();
|
|
20
|
+
const a = arch();
|
|
21
|
+
|
|
22
|
+
if (os === "win32") return "piper_windows_amd64.zip";
|
|
23
|
+
if (os === "darwin") return a === "arm64" ? "piper_macos_aarch64.tar.gz" : "piper_macos_x64.tar.gz";
|
|
24
|
+
// Linux
|
|
25
|
+
if (a === "arm64" || a === "aarch64") return "piper_linux_aarch64.tar.gz";
|
|
26
|
+
return "piper_linux_x86_64.tar.gz";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the piper binary path.
|
|
31
|
+
*/
|
|
32
|
+
function getPiperBinPath() {
|
|
33
|
+
const bin = platform() === "win32" ? "piper.exe" : "piper";
|
|
34
|
+
return join(PIPER_DIR, "piper", bin);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get the voice model path.
|
|
39
|
+
*/
|
|
40
|
+
function getVoiceModelPath() {
|
|
41
|
+
return join(PIPER_DIR, `${VOICE_NAME}.onnx`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Download a file from a URL to a local path.
|
|
46
|
+
*/
|
|
47
|
+
async function downloadFile(url, destPath, onProgress) {
|
|
48
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
49
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
|
50
|
+
const total = parseInt(res.headers.get("content-length") || "0", 10);
|
|
51
|
+
let downloaded = 0;
|
|
52
|
+
|
|
53
|
+
const fileStream = createWriteStream(destPath);
|
|
54
|
+
const reader = res.body.getReader();
|
|
55
|
+
|
|
56
|
+
// Manual stream piping with progress
|
|
57
|
+
while (true) {
|
|
58
|
+
const { done, value } = await reader.read();
|
|
59
|
+
if (done) break;
|
|
60
|
+
fileStream.write(value);
|
|
61
|
+
downloaded += value.length;
|
|
62
|
+
if (onProgress && total > 0) {
|
|
63
|
+
onProgress(Math.round((downloaded / total) * 100));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fileStream.end();
|
|
68
|
+
await new Promise((resolve, reject) => {
|
|
69
|
+
fileStream.on("finish", resolve);
|
|
70
|
+
fileStream.on("error", reject);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract a .tar.gz or .zip archive.
|
|
76
|
+
*/
|
|
77
|
+
async function extractArchive(archivePath, destDir) {
|
|
78
|
+
const os = platform();
|
|
79
|
+
|
|
80
|
+
if (archivePath.endsWith(".zip")) {
|
|
81
|
+
if (os === "win32") {
|
|
82
|
+
// Use PowerShell to extract on Windows
|
|
83
|
+
await new Promise((resolve, reject) => {
|
|
84
|
+
execFile("powershell.exe", [
|
|
85
|
+
"-NoProfile", "-Command",
|
|
86
|
+
`Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`,
|
|
87
|
+
], { windowsHide: true, timeout: 60000 }, (err) => err ? reject(err) : resolve());
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
await new Promise((resolve, reject) => {
|
|
91
|
+
execFile("unzip", ["-o", archivePath, "-d", destDir], { timeout: 60000 }, (err) => err ? reject(err) : resolve());
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
// tar.gz
|
|
96
|
+
await new Promise((resolve, reject) => {
|
|
97
|
+
execFile("tar", ["xzf", archivePath, "-C", destDir], { timeout: 60000 }, (err) => err ? reject(err) : resolve());
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Ensure piper binary is available, downloading if needed.
|
|
104
|
+
* Returns the path to the piper executable.
|
|
105
|
+
*/
|
|
106
|
+
export async function ensurePiper(onProgress) {
|
|
107
|
+
const binPath = getPiperBinPath();
|
|
108
|
+
|
|
109
|
+
// Check if already downloaded
|
|
110
|
+
try {
|
|
111
|
+
await stat(binPath);
|
|
112
|
+
return binPath;
|
|
113
|
+
} catch { /* needs download */ }
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await mkdir(PIPER_DIR, { recursive: true });
|
|
117
|
+
|
|
118
|
+
const asset = getPiperAssetName();
|
|
119
|
+
const url = `https://github.com/rhasspy/piper/releases/download/${PIPER_VERSION}/${asset}`;
|
|
120
|
+
const archivePath = join(PIPER_DIR, asset);
|
|
121
|
+
|
|
122
|
+
if (onProgress) onProgress(`Downloading piper TTS...`);
|
|
123
|
+
await downloadFile(url, archivePath, (pct) => {
|
|
124
|
+
if (onProgress) onProgress(`Downloading piper TTS... ${pct}%`);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (onProgress) onProgress("Extracting piper...");
|
|
128
|
+
await extractArchive(archivePath, PIPER_DIR);
|
|
129
|
+
|
|
130
|
+
// Make executable on Unix
|
|
131
|
+
if (platform() !== "win32") {
|
|
132
|
+
try { await chmod(binPath, 0o755); } catch { /* ignore */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return binPath;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// Clean up partial downloads
|
|
138
|
+
try { const { unlink } = await import("node:fs/promises"); await unlink(join(PIPER_DIR, getPiperAssetName())); } catch { /* ignore */ }
|
|
139
|
+
throw new Error(`Failed to download piper: ${err.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Ensure voice model is available, downloading if needed.
|
|
145
|
+
* Returns the path to the .onnx model file.
|
|
146
|
+
*/
|
|
147
|
+
export async function ensureVoiceModel(onProgress) {
|
|
148
|
+
const modelPath = getVoiceModelPath();
|
|
149
|
+
const configPath = modelPath + ".json";
|
|
150
|
+
|
|
151
|
+
// Check if already downloaded
|
|
152
|
+
try {
|
|
153
|
+
await stat(modelPath);
|
|
154
|
+
await stat(configPath);
|
|
155
|
+
return modelPath;
|
|
156
|
+
} catch { /* needs download */ }
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await mkdir(PIPER_DIR, { recursive: true });
|
|
160
|
+
|
|
161
|
+
const baseUrl = `https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_GB/alan/medium`;
|
|
162
|
+
|
|
163
|
+
if (onProgress) onProgress("Downloading voice model...");
|
|
164
|
+
await downloadFile(`${baseUrl}/${VOICE_NAME}.onnx`, modelPath, (pct) => {
|
|
165
|
+
if (onProgress) onProgress(`Downloading voice model... ${pct}%`);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (onProgress) onProgress("Downloading voice config...");
|
|
169
|
+
await downloadFile(`${baseUrl}/${VOICE_NAME}.onnx.json`, configPath);
|
|
170
|
+
|
|
171
|
+
return modelPath;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// Clean up partial downloads
|
|
174
|
+
const { unlink } = await import("node:fs/promises");
|
|
175
|
+
try { await unlink(modelPath); } catch { /* ignore */ }
|
|
176
|
+
try { await unlink(configPath); } catch { /* ignore */ }
|
|
177
|
+
throw new Error(`Failed to download voice model: ${err.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Speak text using Piper TTS.
|
|
183
|
+
* Auto-downloads piper and voice model on first use.
|
|
184
|
+
* Returns a promise that resolves when speech is done.
|
|
185
|
+
*/
|
|
186
|
+
export async function speak(text, onProgress) {
|
|
187
|
+
if (!text) return;
|
|
188
|
+
|
|
189
|
+
let piperBin, modelPath;
|
|
190
|
+
try {
|
|
191
|
+
[piperBin, modelPath] = await Promise.all([
|
|
192
|
+
ensurePiper(onProgress),
|
|
193
|
+
ensureVoiceModel(onProgress),
|
|
194
|
+
]);
|
|
195
|
+
} catch {
|
|
196
|
+
// TTS unavailable (download failed, offline, etc.) — skip silently
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Generate to temp wav file
|
|
201
|
+
const hash = createHash("md5").update(text).digest("hex").slice(0, 8);
|
|
202
|
+
const outPath = join(tmpdir(), `klaudio-tts-${hash}.wav`);
|
|
203
|
+
|
|
204
|
+
await new Promise((resolve, reject) => {
|
|
205
|
+
const child = execFile(piperBin, [
|
|
206
|
+
"--model", modelPath,
|
|
207
|
+
"--output_file", outPath,
|
|
208
|
+
], { windowsHide: true, timeout: 15000 }, (err) => {
|
|
209
|
+
if (err) reject(err);
|
|
210
|
+
else resolve();
|
|
211
|
+
});
|
|
212
|
+
// Feed text via stdin
|
|
213
|
+
child.stdin.write(text);
|
|
214
|
+
child.stdin.end();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Play the generated wav
|
|
218
|
+
const { playSoundWithCancel } = await import("./player.js");
|
|
219
|
+
await playSoundWithCancel(outPath, { maxSeconds: 0 }).promise.catch(() => {});
|
|
220
|
+
}
|