claude-sound 0.1.9 → 0.2.2
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 +16 -3
- package/assets/images/cli-installation.png +0 -0
- package/package.json +4 -3
- package/src/cli.js +47 -6
- package/src/play.js +120 -5
- package/src/sounds.js +28 -3
- package/src/tts.js +77 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# claude-sound
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cross-platform CLI (macOS, Windows, Linux) that configures **Claude Code Hooks** to play **bundled sounds**.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
@@ -19,7 +19,7 @@ You’ll be prompted to choose where to write settings:
|
|
|
19
19
|
- Project (local): `.claude/settings.local.json`
|
|
20
20
|
- Global: `~/.claude/settings.json`
|
|
21
21
|
|
|
22
|
-
Then you can enable/disable events and choose a sound per event. Selecting a sound plays a quick preview.
|
|
22
|
+
Then you can enable/disable events and choose a sound per event. Selecting a sound plays a quick preview. Choose **Create my own** to generate custom text-to-speech sounds.
|
|
23
23
|
|
|
24
24
|
## Commands
|
|
25
25
|
|
|
@@ -84,7 +84,20 @@ Add `assets/sounds/order.json` to control order and display names:
|
|
|
84
84
|
- Use full IDs (e.g. `common/baemin`). Sounds not listed append at the end.
|
|
85
85
|
- Use `{ "id": "...", "label": "Display Name" }` for custom labels; otherwise the filename is shown.
|
|
86
86
|
|
|
87
|
+
## Create my own (text-to-speech)
|
|
88
|
+
|
|
89
|
+
When picking a sound, choose **Create my own** to generate custom sounds from text. Enter any phrase (e.g. "Claude is ready!") and it will be turned into speech using Google Translate TTS (free, no API key). Requires network. Custom sounds are saved to `~/.claude-sound/sounds/`.
|
|
90
|
+
|
|
91
|
+
See [docs/TTS.md](docs/TTS.md) for details.
|
|
92
|
+
|
|
93
|
+
## Platform support
|
|
94
|
+
|
|
95
|
+
| Platform | Audio player | Notes |
|
|
96
|
+
|----------|--------------|-------|
|
|
97
|
+
| **macOS** | `afplay` | Built-in, no setup needed |
|
|
98
|
+
| **Windows** | `ffplay`, `mpv`, `mpg123`, or PowerShell | Install [ffmpeg](https://ffmpeg.org/) (includes `ffplay`) or [mpv](https://mpv.io/) for best support. PowerShell (built-in) plays WAV only. |
|
|
99
|
+
| **Linux** | `ffplay`, `mpv`, `mpg123`, `aplay`, etc. | Install ffmpeg or mpv for MP3 support. |
|
|
100
|
+
|
|
87
101
|
## Notes
|
|
88
102
|
|
|
89
|
-
- macOS only (requires `afplay`).
|
|
90
103
|
- Hooks run `npx` each time the event fires. It’s simple and works everywhere, but may be slower than a local install.
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-sound",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Configure Claude Code hooks to play bundled sounds
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Configure Claude Code hooks to play bundled sounds (macOS, Windows, Linux).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/beefiker/claude-sound",
|
|
7
7
|
"repository": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"prepack": "npm run generate:sounds"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@clack/prompts": "^1.0.0"
|
|
30
|
+
"@clack/prompts": "^1.0.0",
|
|
31
|
+
"@sefinek/google-tts-api": "^2.1.11"
|
|
31
32
|
}
|
|
32
33
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { intro, outro, select, isCancel, cancel, note, spinner } from '@clack/prompts';
|
|
3
|
+
import { intro, outro, select, text, isCancel, cancel, note, spinner } from '@clack/prompts';
|
|
4
4
|
import pc from 'picocolors';
|
|
5
5
|
import process from 'node:process';
|
|
6
6
|
import fs from 'node:fs/promises';
|
|
7
7
|
import { playSound } from './play.js';
|
|
8
|
-
import { listSounds, listSoundsGrouped, ensureSoundsLoaded } from './sounds.js';
|
|
8
|
+
import { listSounds, listSoundsGrouped, ensureSoundsLoaded, invalidateSoundCache } from './sounds.js';
|
|
9
|
+
import { generateTts } from './tts.js';
|
|
9
10
|
import { selectWithSoundPreview } from './select-with-preview.js';
|
|
10
11
|
import {
|
|
11
12
|
HOOK_EVENTS,
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
|
|
19
20
|
function usage(exitCode = 0) {
|
|
20
21
|
process.stdout.write(`\
|
|
21
|
-
claude-sound (macOS)\n\nUsage:\n npx claude-sound@latest Interactive hook sound setup\n claude-sound Interactive hook sound setup\n\n claude-sound play --sound <id> Play a bundled sound
|
|
22
|
+
claude-sound (macOS, Windows, Linux)\n\nUsage:\n npx claude-sound@latest Interactive hook sound setup\n claude-sound Interactive hook sound setup\n\n claude-sound play --sound <id> Play a bundled sound\n claude-sound list-sounds List bundled sound ids\n claude-sound list-events List Claude hook event names\n\nOptions:\n -h, --help Show help\n\nExamples:\n npx claude-sound@latest\n npx claude-sound@latest play --sound ring1\n`);
|
|
22
23
|
process.exit(exitCode);
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -31,7 +32,9 @@ function parseArg(flag) {
|
|
|
31
32
|
const SOUND_GROUPS = [
|
|
32
33
|
{ value: 'common', label: 'Common' },
|
|
33
34
|
{ value: 'game', label: 'Game' },
|
|
34
|
-
{ value: 'ring', label: 'Ring' }
|
|
35
|
+
{ value: 'ring', label: 'Ring' },
|
|
36
|
+
{ value: 'custom', label: 'Custom (TTS)' },
|
|
37
|
+
{ value: '__create__', label: 'Create my own (text-to-speech)' }
|
|
35
38
|
];
|
|
36
39
|
|
|
37
40
|
/**
|
|
@@ -42,7 +45,11 @@ const SOUND_GROUPS = [
|
|
|
42
45
|
*/
|
|
43
46
|
function formatSoundDisplay(soundId, labels) {
|
|
44
47
|
const displayName = labels[soundId] ?? (soundId.includes('/') ? soundId.split('/')[1] : soundId);
|
|
45
|
-
const group = SOUND_GROUPS.find(
|
|
48
|
+
const group = SOUND_GROUPS.find(
|
|
49
|
+
(g) =>
|
|
50
|
+
(g.value !== '__create__' && soundId.startsWith(g.value + '/')) ||
|
|
51
|
+
(g.value === 'ring' && !soundId.includes('/'))
|
|
52
|
+
);
|
|
46
53
|
return group ? `${group.label} / ${displayName}` : displayName;
|
|
47
54
|
}
|
|
48
55
|
|
|
@@ -179,7 +186,9 @@ async function interactiveSetup() {
|
|
|
179
186
|
continue;
|
|
180
187
|
}
|
|
181
188
|
|
|
182
|
-
const categoryOptions = SOUND_GROUPS.filter(
|
|
189
|
+
const categoryOptions = SOUND_GROUPS.filter(
|
|
190
|
+
(g) => g.value === '__create__' || (soundsGrouped[g.value]?.length ?? 0) > 0
|
|
191
|
+
);
|
|
183
192
|
|
|
184
193
|
while (true) {
|
|
185
194
|
const category = await select({
|
|
@@ -189,6 +198,38 @@ async function interactiveSetup() {
|
|
|
189
198
|
|
|
190
199
|
if (isCancel(category)) break;
|
|
191
200
|
|
|
201
|
+
if (category === '__create__') {
|
|
202
|
+
const textInput = await text({
|
|
203
|
+
message: 'Enter text to speak (e.g. "Claude is ready!")',
|
|
204
|
+
placeholder: 'Claude is ready!',
|
|
205
|
+
validate: (v) => {
|
|
206
|
+
if (!v?.trim()) return 'Text cannot be empty';
|
|
207
|
+
if (v.length > 200) return 'Keep it under 200 characters';
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (isCancel(textInput)) continue;
|
|
213
|
+
|
|
214
|
+
const s = spinner();
|
|
215
|
+
s.start('Generating speech...');
|
|
216
|
+
try {
|
|
217
|
+
const { soundId: newSoundId } = await generateTts(textInput);
|
|
218
|
+
invalidateSoundCache();
|
|
219
|
+
const refreshed = await listSoundsGrouped();
|
|
220
|
+
soundsGrouped.custom = refreshed.grouped.custom;
|
|
221
|
+
soundLabels[newSoundId] = refreshed.labels[newSoundId] ?? textInput.trim().slice(0, 30);
|
|
222
|
+
s.stop('Done');
|
|
223
|
+
mappings[eventName] = newSoundId;
|
|
224
|
+
note(`Created and selected: ${newSoundId}`, 'Created');
|
|
225
|
+
break;
|
|
226
|
+
} catch (err) {
|
|
227
|
+
s.stop('Failed');
|
|
228
|
+
note(String(err?.message ?? err), 'Error');
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
192
233
|
const ids = soundsGrouped[category] ?? [];
|
|
193
234
|
const soundOptions = buildSoundOptionsForGroup(ids, soundLabels);
|
|
194
235
|
|
package/src/play.js
CHANGED
|
@@ -1,6 +1,114 @@
|
|
|
1
|
-
import { execFile, spawn } from 'node:child_process';
|
|
1
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { platform } from 'node:os';
|
|
2
3
|
import { resolveSoundPath } from './sounds.js';
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Player config: { name, check, args }
|
|
7
|
+
* - name: executable name
|
|
8
|
+
* - check: sync function to verify availability (returns boolean)
|
|
9
|
+
* - args: (filePath) => string[] - args to pass to the executable
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if an executable exists in PATH.
|
|
14
|
+
* Uses command -v (POSIX) on Unix, where.exe on Windows.
|
|
15
|
+
*/
|
|
16
|
+
function findExecutable(name) {
|
|
17
|
+
try {
|
|
18
|
+
if (platform() === 'win32') {
|
|
19
|
+
execFileSync('where.exe', [name], { stdio: 'pipe', windowsHide: true });
|
|
20
|
+
} else {
|
|
21
|
+
execFileSync('sh', ['-c', `command -v ${name}`], { stdio: 'pipe' });
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @type {Array<{ name: string; check: () => boolean; args: (f: string) => string[] }>} */
|
|
30
|
+
const PLAYERS = [
|
|
31
|
+
{
|
|
32
|
+
name: 'afplay',
|
|
33
|
+
check: () => platform() === 'darwin' && findExecutable('afplay'),
|
|
34
|
+
args: (f) => [f]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'ffplay',
|
|
38
|
+
check: () => findExecutable('ffplay'),
|
|
39
|
+
args: (f) => ['-nodisp', '-autoexit', '-loglevel', 'quiet', f]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'mpv',
|
|
43
|
+
check: () => findExecutable('mpv'),
|
|
44
|
+
args: (f) => ['--no-video', '--really-quiet', f]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'mpg123',
|
|
48
|
+
check: () => findExecutable('mpg123'),
|
|
49
|
+
args: (f) => ['-q', f]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'mpg321',
|
|
53
|
+
check: () => findExecutable('mpg321'),
|
|
54
|
+
args: (f) => ['-q', f]
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'mplayer',
|
|
58
|
+
check: () => findExecutable('mplayer'),
|
|
59
|
+
args: (f) => ['-really-quiet', '-vo', 'null', f]
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'aplay',
|
|
63
|
+
check: () => findExecutable('aplay'),
|
|
64
|
+
args: (f) => ['-q', f]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'paplay',
|
|
68
|
+
check: () => findExecutable('paplay'),
|
|
69
|
+
args: (f) => [f]
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'cvlc',
|
|
73
|
+
check: () => findExecutable('cvlc'),
|
|
74
|
+
args: (f) => ['--play-and-exit', '-q', f]
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'powershell.exe',
|
|
78
|
+
check: () => platform() === 'win32',
|
|
79
|
+
args: (f) => {
|
|
80
|
+
const escaped = f.replace(/'/g, "''");
|
|
81
|
+
return [
|
|
82
|
+
'-NoProfile',
|
|
83
|
+
'-Command',
|
|
84
|
+
`(New-Object Media.SoundPlayer '${escaped}').PlaySync()`
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/** @type {{ name: string; args: (f: string) => string[] } | null} */
|
|
91
|
+
let _resolvedPlayer = null;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @returns {{ name: string; args: (f: string) => string[] }}
|
|
95
|
+
* @throws {Error}
|
|
96
|
+
*/
|
|
97
|
+
function getPlayer() {
|
|
98
|
+
if (_resolvedPlayer) return _resolvedPlayer;
|
|
99
|
+
|
|
100
|
+
for (const p of PLAYERS) {
|
|
101
|
+
if (p.check()) {
|
|
102
|
+
_resolvedPlayer = { name: p.name, args: p.args };
|
|
103
|
+
return _resolvedPlayer;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
throw new Error(
|
|
108
|
+
'No audio player found. On Windows/Linux, install ffmpeg (ffplay) or mpv.'
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
4
112
|
/**
|
|
5
113
|
* Play a sound and wait for it to finish.
|
|
6
114
|
* @param {string} soundId
|
|
@@ -8,8 +116,9 @@ import { resolveSoundPath } from './sounds.js';
|
|
|
8
116
|
*/
|
|
9
117
|
export function playSound(soundId) {
|
|
10
118
|
const file = resolveSoundPath(soundId);
|
|
119
|
+
const { name, args } = getPlayer();
|
|
11
120
|
return new Promise((resolve, reject) => {
|
|
12
|
-
execFile(
|
|
121
|
+
execFile(name, args(file), { windowsHide: true }, (err) => {
|
|
13
122
|
if (err) reject(err);
|
|
14
123
|
else resolve();
|
|
15
124
|
});
|
|
@@ -28,11 +137,17 @@ let _previewProcess = null;
|
|
|
28
137
|
export function playSoundPreview(soundId) {
|
|
29
138
|
stopPreview();
|
|
30
139
|
const file = resolveSoundPath(soundId);
|
|
31
|
-
const
|
|
140
|
+
const { name, args } = getPlayer();
|
|
141
|
+
|
|
142
|
+
const proc = spawn(name, args(file), {
|
|
32
143
|
detached: true,
|
|
33
|
-
stdio: 'ignore'
|
|
144
|
+
stdio: 'ignore',
|
|
145
|
+
windowsHide: true
|
|
34
146
|
});
|
|
35
147
|
_previewProcess = proc;
|
|
148
|
+
proc.on('error', () => {
|
|
149
|
+
if (_previewProcess === proc) _previewProcess = null;
|
|
150
|
+
});
|
|
36
151
|
proc.on('exit', () => {
|
|
37
152
|
if (_previewProcess === proc) _previewProcess = null;
|
|
38
153
|
});
|
|
@@ -47,7 +162,7 @@ export function stopPreview() {
|
|
|
47
162
|
if (_previewProcess) {
|
|
48
163
|
try {
|
|
49
164
|
const pid = _previewProcess.pid;
|
|
50
|
-
if (pid != null &&
|
|
165
|
+
if (pid != null && platform() !== 'win32') {
|
|
51
166
|
process.kill(-pid, 'SIGKILL');
|
|
52
167
|
} else {
|
|
53
168
|
_previewProcess.kill('SIGKILL');
|
package/src/sounds.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { customSoundsDir } from './tts.js';
|
|
4
5
|
|
|
5
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
7
|
const __dirname = path.dirname(__filename);
|
|
@@ -16,9 +17,17 @@ export function soundsDir() {
|
|
|
16
17
|
/** @type {SoundPathMap | null} */
|
|
17
18
|
let _soundPathCache = null;
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Clear the sound path cache. Call after adding custom sounds.
|
|
22
|
+
* @returns {void}
|
|
23
|
+
*/
|
|
24
|
+
export function invalidateSoundCache() {
|
|
25
|
+
_soundPathCache = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
/**
|
|
20
29
|
* Build a map of sound id -> absolute file path.
|
|
21
|
-
* Discovers sounds from manifest.json
|
|
30
|
+
* Discovers sounds from manifest.json, subdirs (common/, game/), and custom TTS sounds.
|
|
22
31
|
* @returns {Promise<SoundPathMap>}
|
|
23
32
|
*/
|
|
24
33
|
async function buildSoundPathMap() {
|
|
@@ -57,6 +66,20 @@ async function buildSoundPathMap() {
|
|
|
57
66
|
}
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
// From custom TTS sounds (~/.claude-sound/sounds/)
|
|
70
|
+
const customDir = customSoundsDir();
|
|
71
|
+
try {
|
|
72
|
+
const entries = await fs.readdir(customDir, { withFileTypes: true });
|
|
73
|
+
for (const e of entries) {
|
|
74
|
+
if (e.isFile() && (e.name.endsWith('.mp3') || e.name.endsWith('.wav'))) {
|
|
75
|
+
const id = `custom/${path.basename(e.name, path.extname(e.name))}`;
|
|
76
|
+
map[id] = path.join(customDir, e.name);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// dir missing or not readable
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
_soundPathCache = map;
|
|
61
84
|
return map;
|
|
62
85
|
}
|
|
@@ -99,7 +122,7 @@ async function applyCustomOrder(grouped, labels, base) {
|
|
|
99
122
|
}
|
|
100
123
|
if (!order || typeof order !== 'object') return;
|
|
101
124
|
|
|
102
|
-
for (const key of ['common', 'game', 'ring']) {
|
|
125
|
+
for (const key of ['common', 'game', 'ring', 'custom']) {
|
|
103
126
|
const ids = grouped[key];
|
|
104
127
|
if (!ids?.length) continue;
|
|
105
128
|
const ordered = order[key];
|
|
@@ -136,13 +159,15 @@ export async function listSoundsGrouped() {
|
|
|
136
159
|
const grouped = /** @type {GroupedSounds} */ ({
|
|
137
160
|
common: [],
|
|
138
161
|
game: [],
|
|
139
|
-
ring: []
|
|
162
|
+
ring: [],
|
|
163
|
+
custom: []
|
|
140
164
|
});
|
|
141
165
|
const labels = /** @type {SoundLabels} */ ({});
|
|
142
166
|
|
|
143
167
|
for (const id of Object.keys(map)) {
|
|
144
168
|
if (id.startsWith('common/')) grouped.common.push(id);
|
|
145
169
|
else if (id.startsWith('game/')) grouped.game.push(id);
|
|
170
|
+
else if (id.startsWith('custom/')) grouped.custom.push(id);
|
|
146
171
|
else grouped.ring.push(id);
|
|
147
172
|
}
|
|
148
173
|
|
package/src/tts.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { getAudioBase64 } from '@sefinek/google-tts-api';
|
|
5
|
+
|
|
6
|
+
const CUSTOM_SOUNDS_DIR = path.join(os.homedir(), '.claude-sound', 'sounds');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get the directory for custom TTS sounds.
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function customSoundsDir() {
|
|
13
|
+
return CUSTOM_SOUNDS_DIR;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a filesystem-safe slug from text.
|
|
18
|
+
* @param {string} text
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
function slugify(text) {
|
|
22
|
+
const trimmed = text.trim().slice(0, 40);
|
|
23
|
+
const slug = trimmed
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
26
|
+
.replace(/^-|-$/g, '');
|
|
27
|
+
return slug || 'custom';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate a short hash for uniqueness.
|
|
32
|
+
* @param {string} s
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function shortHash(s) {
|
|
36
|
+
let h = 0;
|
|
37
|
+
for (let i = 0; i < s.length; i++) {
|
|
38
|
+
h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
|
39
|
+
}
|
|
40
|
+
return Math.abs(h).toString(36).slice(0, 6);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate TTS audio from text and save to custom sounds directory.
|
|
45
|
+
* Uses Google Translate TTS (free, no API key). Requires network.
|
|
46
|
+
* @param {string} text - Text to speak
|
|
47
|
+
* @param {object} [opts]
|
|
48
|
+
* @param {string} [opts.lang] - Language code (default: 'en')
|
|
49
|
+
* @returns {Promise<{ soundId: string; filePath: string }>}
|
|
50
|
+
*/
|
|
51
|
+
export async function generateTts(text) {
|
|
52
|
+
const trimmed = text.trim();
|
|
53
|
+
if (!trimmed) {
|
|
54
|
+
throw new Error('Text cannot be empty');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const dir = customSoundsDir();
|
|
58
|
+
await fs.mkdir(dir, { recursive: true });
|
|
59
|
+
|
|
60
|
+
const baseSlug = slugify(trimmed);
|
|
61
|
+
const unique = `${baseSlug}-${shortHash(trimmed)}`;
|
|
62
|
+
const filePath = path.join(dir, `${unique}.mp3`);
|
|
63
|
+
|
|
64
|
+
const base64 = await getAudioBase64(trimmed, {
|
|
65
|
+
lang: 'en',
|
|
66
|
+
slow: false,
|
|
67
|
+
timeout: 15000
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const buffer = Buffer.from(base64, 'base64');
|
|
71
|
+
await fs.writeFile(filePath, buffer);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
soundId: `custom/${unique}`,
|
|
75
|
+
filePath
|
|
76
|
+
};
|
|
77
|
+
}
|