klaudio 0.12.7 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +5 -2
- package/package.json +1 -1
- package/src/cli.js +61 -5
- package/src/extractor.js +1 -2
- package/src/installer.js +8 -5
- package/src/player.js +25 -16
- package/src/tts.js +8 -6
package/bin/cli.js
CHANGED
|
@@ -19,15 +19,18 @@ if (process.argv[2] === "notify") {
|
|
|
19
19
|
process.exit(0);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
// Subcommand: klaudio say "text" [--voice <voice>]
|
|
22
|
+
// Subcommand: klaudio say "text" [--voice <voice>] [--speed <speed>]
|
|
23
23
|
if (process.argv[2] === "say") {
|
|
24
24
|
const args = process.argv.slice(3);
|
|
25
25
|
const text = args.find((a) => !a.startsWith("--"));
|
|
26
26
|
const voice = args.find((a) => a.startsWith("--voice="))?.slice(8)
|
|
27
27
|
|| args[args.indexOf("--voice") + 1];
|
|
28
|
+
const speedArg = args.find((a) => a.startsWith("--speed="))?.slice(8)
|
|
29
|
+
|| args[args.indexOf("--speed") + 1];
|
|
30
|
+
const speed = speedArg ? parseFloat(speedArg) : undefined;
|
|
28
31
|
if (text) {
|
|
29
32
|
const { speak } = await import("../src/tts.js");
|
|
30
|
-
await speak(text, { voice });
|
|
33
|
+
await speak(text, { voice, speed });
|
|
31
34
|
}
|
|
32
35
|
// Hard exit: skip native module destructors (onnxruntime crashes during cleanup)
|
|
33
36
|
process.exit(0);
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"
|
|
|
2
2
|
import { render, Box, Text, useInput, useApp } from "ink";
|
|
3
3
|
import Spinner from "ink-spinner";
|
|
4
4
|
import { PRESETS, EVENTS } from "./presets.js";
|
|
5
|
-
import { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE, isKokoroAvailable } from "./tts.js";
|
|
5
|
+
import { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE, isKokoroAvailable, ensureKokoroInstalled } from "./tts.js";
|
|
6
6
|
import { playSoundWithCancel, getWavDuration } from "./player.js";
|
|
7
7
|
import { getAvailableGames, getSystemSounds } from "./scanner.js";
|
|
8
8
|
import { install, uninstall, getExistingSounds, checkHooksOutdated } from "./installer.js";
|
|
@@ -137,7 +137,9 @@ const NavHint = ({ back = true, extra = "" }) =>
|
|
|
137
137
|
);
|
|
138
138
|
|
|
139
139
|
// ── Screen: Scope ───────────────────────────────────────────────
|
|
140
|
-
const
|
|
140
|
+
const SPEED_OPTIONS = [0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5];
|
|
141
|
+
|
|
142
|
+
const ScopeScreen = ({ onNext, onMusic, onUpdate, tts, onToggleTts, voice, speed, hasKokoro, onCycleVoice, onCycleSpeed, onInstallKokoro, installingKokoro, outdatedReasons }) => {
|
|
141
143
|
const isOutdated = outdatedReasons && outdatedReasons.length > 0;
|
|
142
144
|
const items = [
|
|
143
145
|
...(isOutdated ? [{ label: "⬆ Apply updates", value: "_update" }] : []),
|
|
@@ -166,6 +168,10 @@ const ScopeScreen = ({ onNext, onMusic, onUpdate, tts, onToggleTts, voice, hasKo
|
|
|
166
168
|
import("../src/tts.js").then(({ speak }) =>
|
|
167
169
|
speak(`Hi, I'm ${nextVoice.name}`, { voice: nextVoice.id })
|
|
168
170
|
).finally(() => setPreviewing(false));
|
|
171
|
+
} else if (input === "v" && tts && !hasKokoro && !installingKokoro) {
|
|
172
|
+
onInstallKokoro();
|
|
173
|
+
} else if (input === "s" && tts && hasKokoro) {
|
|
174
|
+
onCycleSpeed();
|
|
169
175
|
} else if (key.return) {
|
|
170
176
|
const v = items[sel].value;
|
|
171
177
|
if (v === "_update") onUpdate();
|
|
@@ -207,6 +213,18 @@ const ScopeScreen = ({ onNext, onMusic, onUpdate, tts, onToggleTts, voice, hasKo
|
|
|
207
213
|
),
|
|
208
214
|
h(Text, { dimColor: true }, " (v to change & preview)"),
|
|
209
215
|
) : null,
|
|
216
|
+
tts && hasKokoro ? h(Box, { marginLeft: 4 },
|
|
217
|
+
h(Text, { color: ACCENT }, `⚡ Speed: ${speed}x`),
|
|
218
|
+
h(Text, { dimColor: true }, " (s to change)"),
|
|
219
|
+
) : null,
|
|
220
|
+
tts && !hasKokoro ? h(Box, { marginLeft: 4 },
|
|
221
|
+
installingKokoro
|
|
222
|
+
? h(Text, { color: "yellow" }, h(Spinner, { type: "dots" }), " Installing Kokoro HD voices...")
|
|
223
|
+
: h(React.Fragment, null,
|
|
224
|
+
h(Text, { color: "yellow" }, "🎙 HD voices available"),
|
|
225
|
+
h(Text, { dimColor: true }, " (v to install Kokoro — 12 voices, ~25MB)"),
|
|
226
|
+
),
|
|
227
|
+
) : null,
|
|
210
228
|
);
|
|
211
229
|
};
|
|
212
230
|
|
|
@@ -1421,11 +1439,13 @@ const ExtractingScreen = ({ game, onDone, onBack }) => {
|
|
|
1421
1439
|
};
|
|
1422
1440
|
|
|
1423
1441
|
// ── Screen: Confirm ─────────────────────────────────────────────
|
|
1424
|
-
const ConfirmScreen = ({ scope, sounds, tts, voice, hasKokoro, onToggleTts, onCycleVoice, onConfirm, onBack }) => {
|
|
1442
|
+
const ConfirmScreen = ({ scope, sounds, tts, voice, speed, hasKokoro, onToggleTts, onCycleVoice, onCycleSpeed, onInstallKokoro, installingKokoro, onConfirm, onBack }) => {
|
|
1425
1443
|
useInput((input, key) => {
|
|
1426
1444
|
if (key.escape) onBack();
|
|
1427
1445
|
else if (input === "t") onToggleTts();
|
|
1428
1446
|
else if (input === "v" && tts && hasKokoro) onCycleVoice();
|
|
1447
|
+
else if (input === "s" && tts && hasKokoro) onCycleSpeed();
|
|
1448
|
+
else if (input === "v" && tts && !hasKokoro && !installingKokoro) onInstallKokoro();
|
|
1429
1449
|
});
|
|
1430
1450
|
|
|
1431
1451
|
const items = [
|
|
@@ -1457,6 +1477,18 @@ const ConfirmScreen = ({ scope, sounds, tts, voice, hasKokoro, onToggleTts, onCy
|
|
|
1457
1477
|
),
|
|
1458
1478
|
h(Text, { dimColor: true }, " (v to change voice)"),
|
|
1459
1479
|
) : null,
|
|
1480
|
+
tts && hasKokoro ? h(Box, { marginLeft: 4 },
|
|
1481
|
+
h(Text, { color: ACCENT }, `⚡ Speed: ${speed}x`),
|
|
1482
|
+
h(Text, { dimColor: true }, " (s to change)"),
|
|
1483
|
+
) : null,
|
|
1484
|
+
tts && !hasKokoro ? h(Box, { marginLeft: 4 },
|
|
1485
|
+
installingKokoro
|
|
1486
|
+
? h(Text, { color: "yellow" }, h(Spinner, { type: "dots" }), " Installing Kokoro HD voices...")
|
|
1487
|
+
: h(React.Fragment, null,
|
|
1488
|
+
h(Text, { color: "yellow" }, "🎙 HD voices available"),
|
|
1489
|
+
h(Text, { dimColor: true }, " (v to install Kokoro — 12 voices, ~25MB)"),
|
|
1490
|
+
),
|
|
1491
|
+
) : null,
|
|
1460
1492
|
),
|
|
1461
1493
|
h(Box, { marginTop: 1, marginLeft: 2 },
|
|
1462
1494
|
h(SelectInput, { indicatorComponent: Indicator, itemComponent: Item, items, onSelect: (item) => {
|
|
@@ -1469,13 +1501,13 @@ const ConfirmScreen = ({ scope, sounds, tts, voice, hasKokoro, onToggleTts, onCy
|
|
|
1469
1501
|
};
|
|
1470
1502
|
|
|
1471
1503
|
// ── Screen: Installing ──────────────────────────────────────────
|
|
1472
|
-
const InstallingScreen = ({ scope, sounds, tts, voice, onDone }) => {
|
|
1504
|
+
const InstallingScreen = ({ scope, sounds, tts, voice, speed, onDone }) => {
|
|
1473
1505
|
useEffect(() => {
|
|
1474
1506
|
const validSounds = {};
|
|
1475
1507
|
for (const [eventId, path] of Object.entries(sounds)) {
|
|
1476
1508
|
if (path) validSounds[eventId] = path;
|
|
1477
1509
|
}
|
|
1478
|
-
install({ scope, sounds: validSounds, tts, voice }).then(onDone).catch((err) => {
|
|
1510
|
+
install({ scope, sounds: validSounds, tts, voice, speed }).then(onDone).catch((err) => {
|
|
1479
1511
|
onDone({ error: err.message });
|
|
1480
1512
|
});
|
|
1481
1513
|
}, []);
|
|
@@ -1581,7 +1613,9 @@ const InstallApp = () => {
|
|
|
1581
1613
|
const [installResult, setInstallResult] = useState(null);
|
|
1582
1614
|
const [tts, setTts] = useState(true);
|
|
1583
1615
|
const [voice, setVoice] = useState(KOKORO_DEFAULT_VOICE);
|
|
1616
|
+
const [speed, setSpeed] = useState(1.0);
|
|
1584
1617
|
const [hasKokoro, setHasKokoro] = useState(false);
|
|
1618
|
+
const [installingKokoro, setInstallingKokoro] = useState(false);
|
|
1585
1619
|
const [outdatedReasons, setOutdatedReasons] = useState([]);
|
|
1586
1620
|
const [musicFiles, setMusicFiles] = useState([]);
|
|
1587
1621
|
const [musicGameName, setMusicGameName] = useState(null);
|
|
@@ -1608,19 +1642,33 @@ const InstallApp = () => {
|
|
|
1608
1642
|
if (preset) setSounds({ ...preset.sounds });
|
|
1609
1643
|
}, []);
|
|
1610
1644
|
|
|
1645
|
+
const handleInstallKokoro = useCallback(() => {
|
|
1646
|
+
setInstallingKokoro(true);
|
|
1647
|
+
ensureKokoroInstalled()
|
|
1648
|
+
.then(() => { setHasKokoro(true); setInstallingKokoro(false); })
|
|
1649
|
+
.catch(() => { setInstallingKokoro(false); });
|
|
1650
|
+
}, []);
|
|
1651
|
+
|
|
1611
1652
|
const content = (() => {
|
|
1612
1653
|
switch (screen) {
|
|
1613
1654
|
case SCREEN.SCOPE:
|
|
1614
1655
|
return h(ScopeScreen, {
|
|
1615
1656
|
tts,
|
|
1616
1657
|
voice,
|
|
1658
|
+
speed,
|
|
1617
1659
|
hasKokoro,
|
|
1660
|
+
installingKokoro,
|
|
1618
1661
|
outdatedReasons,
|
|
1619
1662
|
onToggleTts: () => setTts((v) => !v),
|
|
1620
1663
|
onCycleVoice: () => setVoice((v) => {
|
|
1621
1664
|
const idx = KOKORO_VOICES.findIndex((x) => x.id === v);
|
|
1622
1665
|
return KOKORO_VOICES[(idx + 1) % KOKORO_VOICES.length].id;
|
|
1623
1666
|
}),
|
|
1667
|
+
onCycleSpeed: () => setSpeed((s) => {
|
|
1668
|
+
const idx = SPEED_OPTIONS.indexOf(s);
|
|
1669
|
+
return SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
|
|
1670
|
+
}),
|
|
1671
|
+
onInstallKokoro: handleInstallKokoro,
|
|
1624
1672
|
onNext: (s) => {
|
|
1625
1673
|
setScope(s);
|
|
1626
1674
|
// Refresh sounds/outdated for the selected scope
|
|
@@ -1744,12 +1792,19 @@ const InstallApp = () => {
|
|
|
1744
1792
|
sounds,
|
|
1745
1793
|
tts,
|
|
1746
1794
|
voice,
|
|
1795
|
+
speed,
|
|
1747
1796
|
hasKokoro,
|
|
1797
|
+
installingKokoro,
|
|
1748
1798
|
onToggleTts: () => setTts((v) => !v),
|
|
1749
1799
|
onCycleVoice: () => setVoice((v) => {
|
|
1750
1800
|
const idx = KOKORO_VOICES.findIndex((x) => x.id === v);
|
|
1751
1801
|
return KOKORO_VOICES[(idx + 1) % KOKORO_VOICES.length].id;
|
|
1752
1802
|
}),
|
|
1803
|
+
onCycleSpeed: () => setSpeed((s) => {
|
|
1804
|
+
const idx = SPEED_OPTIONS.indexOf(s);
|
|
1805
|
+
return SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
|
|
1806
|
+
}),
|
|
1807
|
+
onInstallKokoro: handleInstallKokoro,
|
|
1753
1808
|
onConfirm: () => setScreen(SCREEN.INSTALLING),
|
|
1754
1809
|
onBack: () => {
|
|
1755
1810
|
if (selectedGame) setScreen(SCREEN.GAME_SOUNDS);
|
|
@@ -1763,6 +1818,7 @@ const InstallApp = () => {
|
|
|
1763
1818
|
sounds,
|
|
1764
1819
|
tts,
|
|
1765
1820
|
voice,
|
|
1821
|
+
speed,
|
|
1766
1822
|
onDone: (result) => {
|
|
1767
1823
|
setInstallResult(result);
|
|
1768
1824
|
setScreen(SCREEN.DONE);
|
package/src/extractor.js
CHANGED
|
@@ -139,8 +139,7 @@ export async function findPackedAudioFiles(gamePath, maxFiles = 50) {
|
|
|
139
139
|
} else if (entry.isFile()) {
|
|
140
140
|
const ext = extname(entry.name).toLowerCase();
|
|
141
141
|
// Formats vgmstream-cli can convert directly
|
|
142
|
-
|
|
143
|
-
if (ext === ".wem" || ext === ".fsb" || ext === ".bank" || ext === ".bun" || ext === ".pck") {
|
|
142
|
+
if (ext === ".wem" || ext === ".fsb" || ext === ".bank" || ext === ".bun" || ext === ".pck" || ext === ".bnk") {
|
|
144
143
|
results.push({ path: fullPath, name: entry.name, dir });
|
|
145
144
|
}
|
|
146
145
|
}
|
package/src/installer.js
CHANGED
|
@@ -25,8 +25,9 @@ function getTargetDir(scope) {
|
|
|
25
25
|
* @param {string} options.scope - "global" or "project"
|
|
26
26
|
* @param {Record<string, string>} options.sounds - Map of event ID -> source sound file path
|
|
27
27
|
* @param {boolean} [options.tts] - Enable TTS voice summary on task complete
|
|
28
|
+
* @param {number} [options.speed] - Voice speed multiplier (default 1.0)
|
|
28
29
|
*/
|
|
29
|
-
export async function install({ scope, sounds, tts = false, voice } = {}) {
|
|
30
|
+
export async function install({ scope, sounds, tts = false, voice, speed } = {}) {
|
|
30
31
|
const claudeDir = getTargetDir(scope);
|
|
31
32
|
const soundsDir = join(claudeDir, "sounds");
|
|
32
33
|
const settingsFile = join(claudeDir, "settings.json");
|
|
@@ -73,7 +74,7 @@ export async function install({ scope, sounds, tts = false, voice } = {}) {
|
|
|
73
74
|
const hookEvent = event.hookEvent;
|
|
74
75
|
// Enable TTS only for the "stop" event (task complete)
|
|
75
76
|
const useTts = tts && eventId === "stop";
|
|
76
|
-
const playCommand = getHookPlayCommand(soundPath, { tts: useTts, voice });
|
|
77
|
+
const playCommand = getHookPlayCommand(soundPath, { tts: useTts, voice, speed });
|
|
77
78
|
|
|
78
79
|
// Check if there's already a klaudio hook for this event
|
|
79
80
|
if (!settings.hooks[hookEvent]) {
|
|
@@ -119,6 +120,7 @@ export async function install({ scope, sounds, tts = false, voice } = {}) {
|
|
|
119
120
|
async function installApprovalHooks(settings, soundPath, claudeDir) {
|
|
120
121
|
const normalized = soundPath.replace(/\\/g, "/");
|
|
121
122
|
const scriptPath = join(claudeDir, "approval-notify.sh").replace(/\\/g, "/");
|
|
123
|
+
const cliPath = new URL("../bin/cli.js", import.meta.url).pathname;
|
|
122
124
|
|
|
123
125
|
// Write the timer script
|
|
124
126
|
const script = `#!/usr/bin/env bash
|
|
@@ -127,6 +129,7 @@ async function installApprovalHooks(settings, soundPath, claudeDir) {
|
|
|
127
129
|
DELAY=120
|
|
128
130
|
MARKER="/tmp/.claude-approval-pending"
|
|
129
131
|
SOUND="${normalized}"
|
|
132
|
+
CLI="${cliPath}"
|
|
130
133
|
|
|
131
134
|
case "$1" in
|
|
132
135
|
start)
|
|
@@ -139,9 +142,9 @@ case "$1" in
|
|
|
139
142
|
if [ -f "$MARKER" ] && [ "$(head -1 "$MARKER" 2>/dev/null)" = "$TOKEN" ]; then
|
|
140
143
|
PROJECT=$(tail -1 "$MARKER" 2>/dev/null | sed 's|.*[/\\\\]||')
|
|
141
144
|
rm -f "$MARKER"
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
node "$CLI" play "$SOUND" 2>/dev/null
|
|
146
|
+
node "$CLI" notify "\${PROJECT:-project}" "Waiting for your approval" 2>/dev/null
|
|
147
|
+
node "$CLI" say "\${PROJECT:-project} needs your attention" 2>/dev/null
|
|
145
148
|
fi
|
|
146
149
|
) &
|
|
147
150
|
;;
|
package/src/player.js
CHANGED
|
@@ -371,20 +371,22 @@ export async function handlePlayCommand(args) {
|
|
|
371
371
|
const soundFile = args.find((a) => !a.startsWith("-"));
|
|
372
372
|
const tts = args.includes("--tts");
|
|
373
373
|
|
|
374
|
-
// Read stdin (hook JSON)
|
|
374
|
+
// Read stdin (hook JSON) only when TTS or notifications need it
|
|
375
375
|
let hookData = {};
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
376
|
+
if (tts || args.includes("--notify")) {
|
|
377
|
+
try {
|
|
378
|
+
const chunks = [];
|
|
379
|
+
process.stdin.setEncoding("utf-8");
|
|
380
|
+
// Read whatever is available with a short timeout
|
|
381
|
+
const stdinData = await new Promise((res) => {
|
|
382
|
+
const timer = setTimeout(() => { process.stdin.pause(); res(chunks.join("")); }, 500);
|
|
383
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
384
|
+
process.stdin.on("end", () => { clearTimeout(timer); res(chunks.join("")); });
|
|
385
|
+
process.stdin.resume();
|
|
386
|
+
});
|
|
387
|
+
if (stdinData.trim()) hookData = JSON.parse(stdinData);
|
|
388
|
+
} catch { /* no stdin or invalid JSON */ }
|
|
389
|
+
}
|
|
388
390
|
|
|
389
391
|
const notify = args.includes("--notify");
|
|
390
392
|
|
|
@@ -469,12 +471,15 @@ export async function handlePlayCommand(args) {
|
|
|
469
471
|
await soundPromise;
|
|
470
472
|
const voice = args.find((a) => a.startsWith("--voice="))?.slice(8)
|
|
471
473
|
|| args[args.indexOf("--voice") + 1];
|
|
474
|
+
const speedArg = args.find((a) => a.startsWith("--speed="))?.slice(8)
|
|
475
|
+
|| args[args.indexOf("--speed") + 1];
|
|
476
|
+
const speed = speedArg ? parseFloat(speedArg) : undefined;
|
|
472
477
|
// Spawn a detached child process for TTS so the hook can exit immediately
|
|
473
478
|
const { spawn } = await import("node:child_process");
|
|
474
479
|
const child = spawn(process.execPath, [
|
|
475
480
|
"--input-type=module",
|
|
476
481
|
"-e",
|
|
477
|
-
`import{speak}from"${import.meta.resolve("./tts.js")}";await speak(${JSON.stringify(spoken)},{voice:${JSON.stringify(voice || undefined)}});`,
|
|
482
|
+
`import{speak}from"${import.meta.resolve("./tts.js")}";await speak(${JSON.stringify(spoken)},{voice:${JSON.stringify(voice || undefined)},speed:${JSON.stringify(speed || undefined)}});`,
|
|
478
483
|
], {
|
|
479
484
|
detached: true,
|
|
480
485
|
stdio: "ignore",
|
|
@@ -488,10 +493,14 @@ export async function handlePlayCommand(args) {
|
|
|
488
493
|
/**
|
|
489
494
|
* Generate the shell command string for use in Claude Code hooks.
|
|
490
495
|
*/
|
|
491
|
-
export function getHookPlayCommand(soundFilePath, { tts = false, voice, notify = true } = {}) {
|
|
496
|
+
export function getHookPlayCommand(soundFilePath, { tts = false, voice, speed, notify = true } = {}) {
|
|
492
497
|
const normalized = soundFilePath.replace(/\\/g, "/");
|
|
493
498
|
const ttsFlag = tts ? " --tts" : "";
|
|
494
499
|
const voiceFlag = tts && voice ? ` --voice ${voice}` : "";
|
|
500
|
+
const speedFlag = tts && speed && speed !== 1.0 ? ` --speed ${speed}` : "";
|
|
495
501
|
const notifyFlag = notify ? " --notify" : "";
|
|
496
|
-
|
|
502
|
+
// Resolve the CLI path at install time to avoid npx overhead on every hook call.
|
|
503
|
+
// When klaudio is updated and re-installed, hooks are rewritten with the new path.
|
|
504
|
+
const cliPath = new URL("../bin/cli.js", import.meta.url).pathname;
|
|
505
|
+
return `node "${cliPath}" play "${normalized}"${ttsFlag}${voiceFlag}${speedFlag}${notifyFlag}`;
|
|
497
506
|
}
|
package/src/tts.js
CHANGED
|
@@ -133,11 +133,12 @@ async function getKokoro() {
|
|
|
133
133
|
* Speak text using Kokoro TTS.
|
|
134
134
|
* Returns true if successful, false if Kokoro is unavailable.
|
|
135
135
|
*/
|
|
136
|
-
async function speakKokoro(text, voice) {
|
|
136
|
+
async function speakKokoro(text, voice, speed) {
|
|
137
137
|
const tts = await getKokoro();
|
|
138
138
|
const voiceId = voice || KOKORO_DEFAULT_VOICE;
|
|
139
|
+
const voiceSpeed = speed || 1.0;
|
|
139
140
|
|
|
140
|
-
const audio = await tts.generate(text, { voice: voiceId, speed:
|
|
141
|
+
const audio = await tts.generate(text, { voice: voiceId, speed: voiceSpeed });
|
|
141
142
|
|
|
142
143
|
// Save to temp wav and play
|
|
143
144
|
const hash = createHash("md5").update(text + voiceId).digest("hex").slice(0, 8);
|
|
@@ -360,6 +361,7 @@ async function releaseTTSLock() {
|
|
|
360
361
|
* @param {string} text - Text to speak
|
|
361
362
|
* @param {object} [options]
|
|
362
363
|
* @param {string} [options.voice] - Kokoro voice ID (e.g. "af_heart")
|
|
364
|
+
* @param {number} [options.speed] - Voice speed multiplier (default 1.0)
|
|
363
365
|
* @param {Function} [options.onProgress] - Progress callback for downloads
|
|
364
366
|
*/
|
|
365
367
|
export async function speak(text, options = {}) {
|
|
@@ -369,14 +371,14 @@ export async function speak(text, options = {}) {
|
|
|
369
371
|
speaking = true;
|
|
370
372
|
|
|
371
373
|
try {
|
|
372
|
-
const { voice, onProgress } = typeof options === "function"
|
|
373
|
-
? { voice: null, onProgress: options } // backwards compat: speak(text, onProgress)
|
|
374
|
+
const { voice, speed, onProgress } = typeof options === "function"
|
|
375
|
+
? { voice: null, speed: null, onProgress: options } // backwards compat: speak(text, onProgress)
|
|
374
376
|
: options;
|
|
375
377
|
|
|
376
378
|
// Try Kokoro (best quality)
|
|
377
379
|
if (await isKokoroAvailable()) {
|
|
378
380
|
try {
|
|
379
|
-
await speakKokoro(text, voice);
|
|
381
|
+
await speakKokoro(text, voice, speed);
|
|
380
382
|
return;
|
|
381
383
|
} catch {
|
|
382
384
|
// Kokoro failed at runtime — fall through
|
|
@@ -394,4 +396,4 @@ export async function speak(text, options = {}) {
|
|
|
394
396
|
}
|
|
395
397
|
}
|
|
396
398
|
|
|
397
|
-
export { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE };
|
|
399
|
+
export { KOKORO_PRESET_VOICES, KOKORO_VOICES, KOKORO_DEFAULT_VOICE, ensureKokoroInstalled };
|