agentgui 1.0.291 → 1.0.292
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/lib/speech.js +183 -54
- package/package.json +1 -1
package/lib/speech.js
CHANGED
|
@@ -1,18 +1,37 @@
|
|
|
1
1
|
import { createRequire } from 'module';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import
|
|
4
|
+
import os from 'os';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const ROOT = path.dirname(__dirname);
|
|
10
10
|
|
|
11
|
+
// Load modules
|
|
12
|
+
let serverTTS = null;
|
|
11
13
|
let serverSTT = null;
|
|
14
|
+
let audioDecode = null;
|
|
15
|
+
let sttttsmodels = null;
|
|
16
|
+
|
|
17
|
+
try { serverTTS = require('webtalk/server-tts'); } catch(e) { console.warn('[TTS] webtalk/server-tts unavailable:', e.message); }
|
|
12
18
|
try { serverSTT = require('webtalk/server-stt'); } catch(e) { console.warn('[STT] webtalk/server-stt unavailable:', e.message); }
|
|
19
|
+
try { audioDecode = require('audio-decode'); } catch(e) { console.warn('[TTS] audio-decode unavailable:', e.message); }
|
|
20
|
+
try { sttttsmodels = require('sttttsmodels'); } catch(e) { console.warn('[TTS] sttttsmodels unavailable:', e.message); }
|
|
21
|
+
|
|
22
|
+
// Detect webtalk API type: old (server-tts.js with getVoices/synthesizeViaPocket)
|
|
23
|
+
// vs new ONNX (server-tts-onnx.js with encodeVoiceAudio)
|
|
24
|
+
const isOnnxApi = serverTTS && typeof serverTTS.encodeVoiceAudio === 'function';
|
|
25
|
+
const isPocketApi = serverTTS && typeof serverTTS.getVoices === 'function';
|
|
26
|
+
|
|
27
|
+
// Voice directories to scan
|
|
28
|
+
const VOICE_DIRS = [
|
|
29
|
+
path.join(os.homedir(), 'voices'),
|
|
30
|
+
path.join(ROOT, 'voices'),
|
|
31
|
+
'/config/voices',
|
|
32
|
+
];
|
|
13
33
|
|
|
14
|
-
const
|
|
15
|
-
const POCKET_PORT = 8787;
|
|
34
|
+
const AUDIO_EXTENSIONS = ['.wav', '.mp3', '.ogg', '.flac', '.m4a'];
|
|
16
35
|
|
|
17
36
|
const POCKET_TTS_VOICES = [
|
|
18
37
|
{ id: 'default', name: 'Default', gender: 'female', accent: 'French' },
|
|
@@ -26,7 +45,101 @@ const POCKET_TTS_VOICES = [
|
|
|
26
45
|
{ id: 'azelma', name: 'Azelma', gender: 'female', accent: 'French' },
|
|
27
46
|
];
|
|
28
47
|
|
|
29
|
-
const
|
|
48
|
+
const SAMPLE_RATE = 24000;
|
|
49
|
+
|
|
50
|
+
// Embedding cache: voiceId -> {data, shape}
|
|
51
|
+
const voiceEmbeddingCache = new Map();
|
|
52
|
+
|
|
53
|
+
function getModelDir() {
|
|
54
|
+
if (sttttsmodels && sttttsmodels.ttsDir && fs.existsSync(sttttsmodels.ttsDir)) {
|
|
55
|
+
return sttttsmodels.ttsDir;
|
|
56
|
+
}
|
|
57
|
+
// Fallback to persistent cache dir
|
|
58
|
+
return path.join(os.homedir(), '.gmgui', 'models', 'tts');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function findVoiceFile(voiceId) {
|
|
62
|
+
if (!voiceId || voiceId === 'default') return null;
|
|
63
|
+
const baseName = voiceId.replace(/^custom_/, '');
|
|
64
|
+
for (const dir of VOICE_DIRS) {
|
|
65
|
+
for (const ext of AUDIO_EXTENSIONS) {
|
|
66
|
+
const p = path.join(dir, baseName + ext);
|
|
67
|
+
if (fs.existsSync(p)) return p;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function scanVoiceDir(dir) {
|
|
74
|
+
const voices = [];
|
|
75
|
+
try {
|
|
76
|
+
if (!fs.existsSync(dir)) return voices;
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
for (const file of fs.readdirSync(dir)) {
|
|
79
|
+
const ext = path.extname(file).toLowerCase();
|
|
80
|
+
if (!AUDIO_EXTENSIONS.includes(ext)) continue;
|
|
81
|
+
const baseName = path.basename(file, ext);
|
|
82
|
+
if (seen.has(baseName)) continue;
|
|
83
|
+
seen.add(baseName);
|
|
84
|
+
voices.push({
|
|
85
|
+
id: 'custom_' + baseName.replace(/[^a-zA-Z0-9_-]/g, '_'),
|
|
86
|
+
name: baseName.replace(/_/g, ' '),
|
|
87
|
+
gender: 'custom', accent: 'custom', isCustom: true,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
} catch (_) {}
|
|
91
|
+
return voices;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Encode a voice WAV file to an ONNX voice embedding
|
|
95
|
+
async function getVoiceEmbedding(voiceId) {
|
|
96
|
+
if (voiceEmbeddingCache.has(voiceId)) return voiceEmbeddingCache.get(voiceId);
|
|
97
|
+
const voicePath = findVoiceFile(voiceId);
|
|
98
|
+
if (!voicePath) return null;
|
|
99
|
+
if (!audioDecode || !serverTTS || !isOnnxApi) return null;
|
|
100
|
+
|
|
101
|
+
const raw = fs.readFileSync(voicePath);
|
|
102
|
+
const decoded = await audioDecode.default(raw);
|
|
103
|
+
// Get mono float32 PCM, resample to 24kHz if needed
|
|
104
|
+
let pcm = decoded.getChannelData(0);
|
|
105
|
+
if (decoded.sampleRate !== SAMPLE_RATE) {
|
|
106
|
+
// Simple linear resampling
|
|
107
|
+
const ratio = decoded.sampleRate / SAMPLE_RATE;
|
|
108
|
+
const outLen = Math.floor(pcm.length / ratio);
|
|
109
|
+
const resampled = new Float32Array(outLen);
|
|
110
|
+
for (let i = 0; i < outLen; i++) resampled[i] = pcm[Math.floor(i * ratio)];
|
|
111
|
+
pcm = resampled;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const embedding = await serverTTS.encodeVoiceAudio(pcm);
|
|
115
|
+
voiceEmbeddingCache.set(voiceId, embedding);
|
|
116
|
+
return embedding;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Convert Float32Array PCM to WAV buffer
|
|
120
|
+
function pcmToWav(samples, sampleRate = SAMPLE_RATE) {
|
|
121
|
+
const numSamples = samples.length;
|
|
122
|
+
const numChannels = 1;
|
|
123
|
+
const bitsPerSample = 16;
|
|
124
|
+
const byteRate = sampleRate * numChannels * bitsPerSample / 8;
|
|
125
|
+
const blockAlign = numChannels * bitsPerSample / 8;
|
|
126
|
+
const dataSize = numSamples * blockAlign;
|
|
127
|
+
const buf = Buffer.alloc(44 + dataSize);
|
|
128
|
+
|
|
129
|
+
buf.write('RIFF', 0); buf.writeUInt32LE(36 + dataSize, 4);
|
|
130
|
+
buf.write('WAVE', 8); buf.write('fmt ', 12);
|
|
131
|
+
buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20);
|
|
132
|
+
buf.writeUInt16LE(numChannels, 22); buf.writeUInt32LE(sampleRate, 24);
|
|
133
|
+
buf.writeUInt32LE(byteRate, 28); buf.writeUInt16LE(blockAlign, 32);
|
|
134
|
+
buf.writeUInt16LE(bitsPerSample, 34); buf.write('data', 36);
|
|
135
|
+
buf.writeUInt32LE(dataSize, 40);
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < numSamples; i++) {
|
|
138
|
+
const s = Math.max(-1, Math.min(1, samples[i]));
|
|
139
|
+
buf.writeInt16LE(Math.round(s * 32767), 44 + i * 2);
|
|
140
|
+
}
|
|
141
|
+
return buf;
|
|
142
|
+
}
|
|
30
143
|
|
|
31
144
|
function getSttOptions() {
|
|
32
145
|
if (process.env.PORTABLE_EXE_DIR) {
|
|
@@ -38,56 +151,40 @@ function getSttOptions() {
|
|
|
38
151
|
return {};
|
|
39
152
|
}
|
|
40
153
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
154
|
+
async function synthesize(text, voiceId) {
|
|
155
|
+
if (isOnnxApi) {
|
|
156
|
+
// Node.js ONNX TTS - no Python required
|
|
157
|
+
const modelDir = getModelDir();
|
|
158
|
+
const embedding = voiceId ? await getVoiceEmbedding(voiceId) : null;
|
|
159
|
+
const pcm = await serverTTS.synthesize(text, embedding, modelDir);
|
|
160
|
+
return pcmToWav(pcm);
|
|
45
161
|
}
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
162
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const boundary = '----PocketTTS' + Date.now();
|
|
53
|
-
const parts = [];
|
|
54
|
-
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="text"\r\n\r\n${text}\r\n`);
|
|
55
|
-
if (voicePath) {
|
|
56
|
-
const data = fs.readFileSync(voicePath);
|
|
57
|
-
const name = path.basename(voicePath);
|
|
58
|
-
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="voice_wav"; filename="${name}"\r\nContent-Type: audio/wav\r\n\r\n`);
|
|
59
|
-
parts.push(data);
|
|
60
|
-
parts.push('\r\n');
|
|
61
|
-
} else if (isPredefined) {
|
|
62
|
-
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="voice_url"\r\n\r\n${voiceId}\r\n`);
|
|
163
|
+
if (isPocketApi) {
|
|
164
|
+
// Old server-tts.js with pocket-tts sidecar
|
|
165
|
+
return serverTTS.synthesize(text, voiceId, VOICE_DIRS);
|
|
63
166
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return new Promise((resolve, reject) => {
|
|
67
|
-
const req = http.request({
|
|
68
|
-
hostname: '127.0.0.1', port: POCKET_PORT, path: '/tts', method: 'POST',
|
|
69
|
-
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length },
|
|
70
|
-
timeout: 60000,
|
|
71
|
-
}, res => {
|
|
72
|
-
if (res.statusCode !== 200) {
|
|
73
|
-
let e = '';
|
|
74
|
-
res.on('data', d => e += d);
|
|
75
|
-
res.on('end', () => reject(new Error(`pocket-tts HTTP ${res.statusCode}: ${e}`)));
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
const chunks = [];
|
|
79
|
-
res.on('data', d => chunks.push(d));
|
|
80
|
-
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
81
|
-
});
|
|
82
|
-
req.on('error', reject);
|
|
83
|
-
req.on('timeout', () => { req.destroy(); reject(new Error('pocket-tts timeout')); });
|
|
84
|
-
req.write(body);
|
|
85
|
-
req.end();
|
|
86
|
-
});
|
|
167
|
+
|
|
168
|
+
throw new Error('No TTS backend available');
|
|
87
169
|
}
|
|
88
170
|
|
|
89
171
|
async function* synthesizeStream(text, voiceId) {
|
|
90
|
-
|
|
172
|
+
if (isOnnxApi) {
|
|
173
|
+
const modelDir = getModelDir();
|
|
174
|
+
const embedding = voiceId ? await getVoiceEmbedding(voiceId) : null;
|
|
175
|
+
const pcm = await serverTTS.synthesize(text, embedding, modelDir);
|
|
176
|
+
yield pcmToWav(pcm);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (isPocketApi) {
|
|
181
|
+
for await (const chunk of serverTTS.synthesizeStream(text, voiceId, VOICE_DIRS)) {
|
|
182
|
+
yield chunk;
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
throw new Error('No TTS backend available');
|
|
91
188
|
}
|
|
92
189
|
|
|
93
190
|
function transcribe(audioBuffer) {
|
|
@@ -101,29 +198,61 @@ function getSTT() {
|
|
|
101
198
|
}
|
|
102
199
|
|
|
103
200
|
function getVoices() {
|
|
104
|
-
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
const custom = [];
|
|
203
|
+
for (const dir of VOICE_DIRS) {
|
|
204
|
+
for (const v of scanVoiceDir(dir)) {
|
|
205
|
+
if (seen.has(v.id)) continue;
|
|
206
|
+
seen.add(v.id);
|
|
207
|
+
custom.push(v);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Include built-in voices from old server-tts if available
|
|
211
|
+
if (isPocketApi) {
|
|
212
|
+
const upstream = serverTTS.getVoices(VOICE_DIRS).filter(v => v.isCustom);
|
|
213
|
+
for (const v of upstream) {
|
|
214
|
+
if (!seen.has(v.id)) { seen.add(v.id); custom.push(v); }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return [...POCKET_TTS_VOICES, ...custom];
|
|
105
218
|
}
|
|
106
219
|
|
|
107
220
|
function getStatus() {
|
|
108
221
|
const sttStatus = serverSTT ? serverSTT.getStatus() : { ready: false, loading: false, error: 'STT unavailable' };
|
|
222
|
+
const ttsBackend = isOnnxApi ? 'onnx-node' : isPocketApi ? 'pocket-tts' : 'none';
|
|
109
223
|
return {
|
|
110
224
|
sttReady: sttStatus.ready,
|
|
111
|
-
ttsReady:
|
|
225
|
+
ttsReady: isOnnxApi || isPocketApi,
|
|
112
226
|
sttLoading: sttStatus.loading,
|
|
113
227
|
ttsLoading: false,
|
|
114
228
|
sttError: sttStatus.error,
|
|
115
|
-
ttsError: null,
|
|
229
|
+
ttsError: (!isOnnxApi && !isPocketApi) ? 'No TTS backend available' : null,
|
|
230
|
+
ttsBackend,
|
|
116
231
|
};
|
|
117
232
|
}
|
|
118
233
|
|
|
119
234
|
function preloadTTS() {
|
|
120
|
-
|
|
235
|
+
if (isOnnxApi) {
|
|
236
|
+
// Pre-load ONNX models in background
|
|
237
|
+
const modelDir = getModelDir();
|
|
238
|
+
if (serverTTS.loadModels) {
|
|
239
|
+
serverTTS.loadModels(modelDir).catch(e => console.warn('[TTS] ONNX preload failed:', e.message));
|
|
240
|
+
}
|
|
241
|
+
} else if (isPocketApi && serverTTS.preload) {
|
|
242
|
+
serverTTS.preload(null, {});
|
|
243
|
+
}
|
|
121
244
|
}
|
|
122
245
|
|
|
123
|
-
function ttsCacheKey(text, voiceId) {
|
|
124
|
-
|
|
246
|
+
function ttsCacheKey(text, voiceId) {
|
|
247
|
+
return isPocketApi && serverTTS.ttsCacheKey ? serverTTS.ttsCacheKey(text, voiceId) : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function ttsCacheGet(key) {
|
|
251
|
+
return isPocketApi && serverTTS.ttsCacheGet ? serverTTS.ttsCacheGet(key) : null;
|
|
252
|
+
}
|
|
125
253
|
|
|
126
254
|
function splitSentences(text) {
|
|
255
|
+
if (isPocketApi && serverTTS.splitSentences) return serverTTS.splitSentences(text);
|
|
127
256
|
return text.match(/[^.!?]+[.!?]*/g)?.map(s => s.trim()).filter(Boolean) || [text];
|
|
128
257
|
}
|
|
129
258
|
|