agentgui 1.0.169 → 1.0.170
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 +123 -11
- package/package.json +2 -1
- package/static/js/voice.js +93 -54
package/lib/speech.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRequire } from 'module';
|
|
2
2
|
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
3
4
|
import path from 'path';
|
|
4
5
|
import { fileURLToPath } from 'url';
|
|
5
6
|
|
|
@@ -7,16 +8,12 @@ const require = createRequire(import.meta.url);
|
|
|
7
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
const ROOT = path.dirname(__dirname);
|
|
9
10
|
const DATA_DIR = path.join(ROOT, 'data');
|
|
11
|
+
const VOICES_DIR = path.join(ROOT, 'voices');
|
|
12
|
+
const HOME_VOICES_DIR = path.join(os.homedir(), 'voices');
|
|
13
|
+
const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.flac', '.m4a'];
|
|
14
|
+
const MIN_WAV_SIZE = 1000;
|
|
10
15
|
|
|
11
|
-
const
|
|
12
|
-
const SPEAKER_EMBEDDINGS_PATH = path.join(DATA_DIR, 'speaker_embeddings.bin');
|
|
13
|
-
const SAMPLE_RATE_TTS = 16000;
|
|
14
|
-
const SAMPLE_RATE_STT = 16000;
|
|
15
|
-
const MIN_WAV_SIZE = 44;
|
|
16
|
-
const DATASET_API = 'https://datasets-server.huggingface.co/rows?dataset=Matthijs%2Fcmu-arctic-xvectors&config=default&split=validation';
|
|
17
|
-
const SAMPLES_TO_AVERAGE = 10;
|
|
18
|
-
|
|
19
|
-
const VOICE_CATALOG = [
|
|
16
|
+
const BASE_VOICES = [
|
|
20
17
|
{ id: 'default', name: 'Default', gender: 'male', accent: 'US' },
|
|
21
18
|
{ id: 'bdl', name: 'BDL', gender: 'male', accent: 'US' },
|
|
22
19
|
{ id: 'slt', name: 'SLT', gender: 'female', accent: 'US' },
|
|
@@ -27,15 +24,58 @@ const VOICE_CATALOG = [
|
|
|
27
24
|
{ id: 'ksp', name: 'KSP', gender: 'male', accent: 'Indian' },
|
|
28
25
|
];
|
|
29
26
|
|
|
27
|
+
function scanVoiceDir(dir) {
|
|
28
|
+
const voices = [];
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.existsSync(dir)) return voices;
|
|
31
|
+
for (const file of fs.readdirSync(dir)) {
|
|
32
|
+
const ext = path.extname(file).toLowerCase();
|
|
33
|
+
if (!AUDIO_EXTENSIONS.includes(ext)) continue;
|
|
34
|
+
const baseName = path.basename(file, ext);
|
|
35
|
+
const id = 'custom_' + baseName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
36
|
+
const name = baseName.replace(/_/g, ' ');
|
|
37
|
+
voices.push({ id, name, gender: 'custom', accent: 'custom', isCustom: true, sourceDir: dir });
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error('[VOICES] Error scanning', dir + ':', err.message);
|
|
41
|
+
}
|
|
42
|
+
return voices;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadCustomVoices() {
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const voices = [];
|
|
48
|
+
for (const dir of [VOICES_DIR, HOME_VOICES_DIR]) {
|
|
49
|
+
for (const v of scanVoiceDir(dir)) {
|
|
50
|
+
if (seen.has(v.id)) continue;
|
|
51
|
+
seen.add(v.id);
|
|
52
|
+
voices.push(v);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return voices;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getVoices() {
|
|
59
|
+
return [...BASE_VOICES, ...loadCustomVoices()];
|
|
60
|
+
}
|
|
61
|
+
|
|
30
62
|
const SPEAKER_OFFSETS = { awb: 0, bdl: 1200, clb: 2300, jmk: 3500, ksp: 4700, rms: 5900, slt: 7100 };
|
|
63
|
+
const SPEAKER_EMBEDDINGS_URL = 'https://huggingface.co/datasets/Xenova/speaker_embeddings/resolve/main/spkrec-xvectors-voxceleb.hf';
|
|
64
|
+
const SPEAKER_EMBEDDINGS_PATH = path.join(DATA_DIR, 'speaker_embeddings.bin');
|
|
65
|
+
const DATASET_API = 'https://datasets-server.huggingface.co/rows?dataset=Xenova%2Fspeaker_embeddings&config=default&split=train';
|
|
66
|
+
const SAMPLES_TO_AVERAGE = 30;
|
|
31
67
|
|
|
32
68
|
let transformersModule = null;
|
|
33
69
|
let sttPipeline = null;
|
|
34
70
|
let ttsPipeline = null;
|
|
35
71
|
let speakerEmbeddings = null;
|
|
72
|
+
let speakerEmbeddingPipeline = null;
|
|
36
73
|
let sttLoading = false;
|
|
37
74
|
let ttsLoading = false;
|
|
75
|
+
let speakerEmbeddingLoading = false;
|
|
38
76
|
const voiceEmbeddingsCache = new Map();
|
|
77
|
+
const SAMPLE_RATE_STT = 16000;
|
|
78
|
+
const SAMPLE_RATE_TTS = 16000;
|
|
39
79
|
|
|
40
80
|
const TTS_CACHE_MAX = 100;
|
|
41
81
|
const ttsCache = new Map();
|
|
@@ -78,6 +118,9 @@ async function loadVoiceEmbedding(voiceId) {
|
|
|
78
118
|
voiceEmbeddingsCache.set(voiceId, emb);
|
|
79
119
|
return emb;
|
|
80
120
|
}
|
|
121
|
+
if (voiceId.startsWith('custom_')) {
|
|
122
|
+
return generateEmbeddingFromCustomVoice(voiceId);
|
|
123
|
+
}
|
|
81
124
|
const offset = SPEAKER_OFFSETS[voiceId];
|
|
82
125
|
if (offset === undefined) return ensureSpeakerEmbeddings();
|
|
83
126
|
const url = `${DATASET_API}&offset=${offset}&length=${SAMPLES_TO_AVERAGE}`;
|
|
@@ -101,8 +144,77 @@ async function loadVoiceEmbedding(voiceId) {
|
|
|
101
144
|
return avg;
|
|
102
145
|
}
|
|
103
146
|
|
|
104
|
-
function
|
|
105
|
-
return
|
|
147
|
+
async function getSpeakerEmbeddingPipeline() {
|
|
148
|
+
if (speakerEmbeddingPipeline) return speakerEmbeddingPipeline;
|
|
149
|
+
if (speakerEmbeddingLoading) {
|
|
150
|
+
while (speakerEmbeddingLoading) await new Promise(r => setTimeout(r, 100));
|
|
151
|
+
if (!speakerEmbeddingPipeline) throw new Error('Speaker embedding pipeline failed to load');
|
|
152
|
+
return speakerEmbeddingPipeline;
|
|
153
|
+
}
|
|
154
|
+
speakerEmbeddingLoading = true;
|
|
155
|
+
try {
|
|
156
|
+
const { pipeline, env } = await loadTransformers();
|
|
157
|
+
env.allowRemoteModels = true;
|
|
158
|
+
speakerEmbeddingPipeline = await pipeline('feature-extraction', 'speechbrain/spkrec-xvectors-voxceleb', {
|
|
159
|
+
device: 'cpu',
|
|
160
|
+
dtype: 'fp32',
|
|
161
|
+
});
|
|
162
|
+
return speakerEmbeddingPipeline;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
speakerEmbeddingPipeline = null;
|
|
165
|
+
throw new Error('Speaker embedding model load failed: ' + err.message);
|
|
166
|
+
} finally {
|
|
167
|
+
speakerEmbeddingLoading = false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function findCustomVoiceFile(voiceId) {
|
|
172
|
+
const baseName = voiceId.replace(/^custom_/, '');
|
|
173
|
+
for (const dir of [VOICES_DIR, HOME_VOICES_DIR]) {
|
|
174
|
+
for (const ext of AUDIO_EXTENSIONS) {
|
|
175
|
+
const candidate = path.join(dir, baseName + ext);
|
|
176
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function decodeAudioFile(filePath) {
|
|
183
|
+
const buf = fs.readFileSync(filePath);
|
|
184
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
185
|
+
if (ext === '.wav') {
|
|
186
|
+
const decoded = decodeWavToFloat32(buf);
|
|
187
|
+
return resampleTo16k(decoded.audio, decoded.sampleRate);
|
|
188
|
+
}
|
|
189
|
+
const decode = (await import('audio-decode')).default;
|
|
190
|
+
const audioBuffer = await decode(buf);
|
|
191
|
+
const mono = audioBuffer.getChannelData(0);
|
|
192
|
+
return resampleTo16k(mono, audioBuffer.sampleRate);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function generateEmbeddingFromCustomVoice(voiceId) {
|
|
196
|
+
const audioFile = findCustomVoiceFile(voiceId);
|
|
197
|
+
if (!audioFile) {
|
|
198
|
+
console.error('[VOICES] Custom voice file not found for:', voiceId);
|
|
199
|
+
return ensureSpeakerEmbeddings();
|
|
200
|
+
}
|
|
201
|
+
console.log('[VOICES] Generating embedding from:', audioFile);
|
|
202
|
+
const audio = await decodeAudioFile(audioFile);
|
|
203
|
+
if (audio.length < SAMPLE_RATE_STT * 0.5) {
|
|
204
|
+
throw new Error('Audio too short for embedding extraction (need at least 0.5 seconds)');
|
|
205
|
+
}
|
|
206
|
+
const pipe = await getSpeakerEmbeddingPipeline();
|
|
207
|
+
const output = await pipe(audio, { pooling: 'mean', normalize: true });
|
|
208
|
+
const embedding = new Float32Array(512);
|
|
209
|
+
for (let i = 0; i < Math.min(512, output.data.length); i++) {
|
|
210
|
+
embedding[i] = output.data[i];
|
|
211
|
+
}
|
|
212
|
+
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
213
|
+
const binPath = path.join(DATA_DIR, `speaker_${voiceId}.bin`);
|
|
214
|
+
fs.writeFileSync(binPath, Buffer.from(embedding.buffer));
|
|
215
|
+
voiceEmbeddingsCache.set(voiceId, embedding);
|
|
216
|
+
console.log('[VOICES] Generated embedding for custom voice:', voiceId);
|
|
217
|
+
return embedding;
|
|
106
218
|
}
|
|
107
219
|
|
|
108
220
|
async function getSTT() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentgui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.170",
|
|
4
4
|
"description": "Multi-agent ACP client with real-time communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@anthropic-ai/claude-code": "^2.1.37",
|
|
25
25
|
"@huggingface/transformers": "^3.8.1",
|
|
26
|
+
"audio-decode": "^2.2.3",
|
|
26
27
|
"better-sqlite3": "^12.6.2",
|
|
27
28
|
"busboy": "^1.6.0",
|
|
28
29
|
"express": "^5.2.1",
|
package/static/js/voice.js
CHANGED
|
@@ -34,19 +34,33 @@
|
|
|
34
34
|
.then(function(data) {
|
|
35
35
|
if (!data.ok || !Array.isArray(data.voices)) return;
|
|
36
36
|
selector.innerHTML = '';
|
|
37
|
-
data.voices.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
var
|
|
41
|
-
|
|
37
|
+
var builtIn = data.voices.filter(function(v) { return !v.isCustom; });
|
|
38
|
+
var custom = data.voices.filter(function(v) { return v.isCustom; });
|
|
39
|
+
if (builtIn.length) {
|
|
40
|
+
var grp1 = document.createElement('optgroup');
|
|
41
|
+
grp1.label = 'Built-in Voices';
|
|
42
|
+
builtIn.forEach(function(voice) {
|
|
43
|
+
var opt = document.createElement('option');
|
|
44
|
+
opt.value = voice.id;
|
|
42
45
|
var parts = [];
|
|
43
46
|
if (voice.gender) parts.push(voice.gender);
|
|
44
47
|
if (voice.accent) parts.push(voice.accent);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
selector.appendChild(
|
|
49
|
-
}
|
|
48
|
+
opt.textContent = voice.name + (parts.length ? ' (' + parts.join(', ') + ')' : '');
|
|
49
|
+
grp1.appendChild(opt);
|
|
50
|
+
});
|
|
51
|
+
selector.appendChild(grp1);
|
|
52
|
+
}
|
|
53
|
+
if (custom.length) {
|
|
54
|
+
var grp2 = document.createElement('optgroup');
|
|
55
|
+
grp2.label = 'Custom Voices';
|
|
56
|
+
custom.forEach(function(voice) {
|
|
57
|
+
var opt = document.createElement('option');
|
|
58
|
+
opt.value = voice.id;
|
|
59
|
+
opt.textContent = voice.name;
|
|
60
|
+
grp2.appendChild(opt);
|
|
61
|
+
});
|
|
62
|
+
selector.appendChild(grp2);
|
|
63
|
+
}
|
|
50
64
|
if (saved && selector.querySelector('option[value="' + saved + '"]')) {
|
|
51
65
|
selector.value = saved;
|
|
52
66
|
}
|
|
@@ -322,53 +336,78 @@
|
|
|
322
336
|
var text = speechQueue.shift();
|
|
323
337
|
audioChunkQueue = [];
|
|
324
338
|
isPlayingChunk = false;
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
339
|
+
|
|
340
|
+
function tryStreaming() {
|
|
341
|
+
fetch(BASE + '/api/tts-stream', {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers: { 'Content-Type': 'application/json' },
|
|
344
|
+
body: JSON.stringify({ text: text, voiceId: selectedVoiceId })
|
|
345
|
+
}).then(function(resp) {
|
|
346
|
+
if (!resp.ok) throw new Error('TTS stream failed');
|
|
347
|
+
var reader = resp.body.getReader();
|
|
348
|
+
var buffer = new Uint8Array(0);
|
|
349
|
+
|
|
350
|
+
function concat(a, b) {
|
|
351
|
+
var c = new Uint8Array(a.length + b.length);
|
|
352
|
+
c.set(a, 0);
|
|
353
|
+
c.set(b, a.length);
|
|
354
|
+
return c;
|
|
355
|
+
}
|
|
340
356
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
357
|
+
function pump() {
|
|
358
|
+
return reader.read().then(function(result) {
|
|
359
|
+
if (result.done) {
|
|
360
|
+
streamDone = true;
|
|
361
|
+
if (!isPlayingChunk && audioChunkQueue.length === 0) {
|
|
362
|
+
isSpeaking = false;
|
|
363
|
+
processQueue();
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
348
366
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
});
|
|
364
|
-
}
|
|
367
|
+
buffer = concat(buffer, result.value);
|
|
368
|
+
while (buffer.length >= 4) {
|
|
369
|
+
var view = new DataView(buffer.buffer, buffer.byteOffset, 4);
|
|
370
|
+
var chunkLen = view.getUint32(0, false);
|
|
371
|
+
if (buffer.length < 4 + chunkLen) break;
|
|
372
|
+
var wavData = buffer.slice(4, 4 + chunkLen);
|
|
373
|
+
buffer = buffer.slice(4 + chunkLen);
|
|
374
|
+
var blob = new Blob([wavData], { type: 'audio/wav' });
|
|
375
|
+
audioChunkQueue.push(blob);
|
|
376
|
+
if (!isPlayingChunk) playNextChunk();
|
|
377
|
+
}
|
|
378
|
+
return pump();
|
|
379
|
+
});
|
|
380
|
+
}
|
|
365
381
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
382
|
+
return pump();
|
|
383
|
+
}).catch(function() {
|
|
384
|
+
tryNonStreaming(text);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function tryNonStreaming(txt) {
|
|
389
|
+
fetch(BASE + '/api/tts', {
|
|
390
|
+
method: 'POST',
|
|
391
|
+
headers: { 'Content-Type': 'application/json' },
|
|
392
|
+
body: JSON.stringify({ text: txt, voiceId: selectedVoiceId })
|
|
393
|
+
}).then(function(resp) {
|
|
394
|
+
if (!resp.ok) throw new Error('TTS failed');
|
|
395
|
+
return resp.arrayBuffer();
|
|
396
|
+
}).then(function(buf) {
|
|
397
|
+
var blob = new Blob([buf], { type: 'audio/wav' });
|
|
398
|
+
audioChunkQueue.push(blob);
|
|
399
|
+
if (!isPlayingChunk) playNextChunk();
|
|
400
|
+
streamDone = true;
|
|
401
|
+
isSpeaking = false;
|
|
402
|
+
processQueue();
|
|
403
|
+
}).catch(function() {
|
|
404
|
+
streamDone = true;
|
|
405
|
+
isSpeaking = false;
|
|
406
|
+
processQueue();
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
tryStreaming();
|
|
372
411
|
}
|
|
373
412
|
|
|
374
413
|
function stopSpeaking() {
|