@chatpanel/gateway 0.6.59 → 0.6.60
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/package.json +1 -1
- package/src/server.js +21 -74
- package/src/tts-voice-resolve.js +78 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.60",
|
|
4
4
|
"description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/server.js
CHANGED
|
@@ -44,6 +44,7 @@ import { TTS_MODEL_CATALOG, TTS_VOICES, isKnownTtsModel, isValidCustomTtsId, isK
|
|
|
44
44
|
import { ttsDestination, synthesizeRemote, isValidRemoteVoice } from './tts-remote.js';
|
|
45
45
|
import { rawOrtAvailable } from './ort.js';
|
|
46
46
|
import * as ttsVoices from './tts-voices.js';
|
|
47
|
+
import { resolveTtsVoice } from './tts-voice-resolve.js';
|
|
47
48
|
import { resolvePro, checkQuota, consume, usage } from './freegate.js';
|
|
48
49
|
import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath } from './configstore.js';
|
|
49
50
|
import { resolveDestination, aggregateModelsAsync, listDestinations } from './router.js';
|
|
@@ -54,7 +55,7 @@ import * as openai from './openai.js';
|
|
|
54
55
|
import * as responses from './responses.js';
|
|
55
56
|
import * as anthropic from './anthropic.js';
|
|
56
57
|
|
|
57
|
-
export const VERSION = '0.6.
|
|
58
|
+
export const VERSION = '0.6.60';
|
|
58
59
|
|
|
59
60
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
60
61
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -1078,8 +1079,15 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1078
1079
|
const curId = ttsVoices.parseCustomVoice(cfg.tts.voice || '');
|
|
1079
1080
|
const isCustom = !!(curId && ttsVoices.getVoice(curId));
|
|
1080
1081
|
if (wantsCustom && !isCustom) {
|
|
1081
|
-
|
|
1082
|
-
|
|
1082
|
+
// Switching to a cloning model does not mean "start speaking as the user".
|
|
1083
|
+
// A model with built-in speakers gets its default speaker; only SpeechT5,
|
|
1084
|
+
// which has no built-ins, falls back to a saved voice.
|
|
1085
|
+
if (ttsModelEngineOf(id) === 'pocket-tts') {
|
|
1086
|
+
cfg.tts.voice = DEFAULT_POCKET_VOICE;
|
|
1087
|
+
} else {
|
|
1088
|
+
const saved = ttsVoices.listVoices();
|
|
1089
|
+
cfg.tts.voice = saved.length ? `custom:${saved[0].id}` : '';
|
|
1090
|
+
}
|
|
1083
1091
|
} else if (!wantsCustom && isCustom) {
|
|
1084
1092
|
cfg.tts.voice = DEFAULT_TTS_VOICE;
|
|
1085
1093
|
}
|
|
@@ -1280,76 +1288,13 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1280
1288
|
// SpeechT5 takes a recorded embedding, VITS takes neither. Resolving first
|
|
1281
1289
|
// meant a `custom:` voice could reach a freshly-loaded Kokoro and fail deep
|
|
1282
1290
|
// in the engine with "invalid voice id".
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
if (ttsEngine.isPocket() && isPocketVoice(rawVoice || useVoice)) {
|
|
1291
|
-
useVoice = rawVoice || useVoice;
|
|
1292
|
-
customId = null;
|
|
1293
|
-
} else if (ttsEngine.supportsCustomVoices()) {
|
|
1294
|
-
// This model speaks ONLY in a recorded voice. If the configured one names
|
|
1295
|
-
// a built-in (switching model does not rewrite `voice`) or points at a
|
|
1296
|
-
// voice since deleted, fall back to the most recent saved one — the
|
|
1297
|
-
// caller asked to be spoken to, not for that exact voice. A voice named
|
|
1298
|
-
// EXPLICITLY in the request still fails loudly.
|
|
1299
|
-
let rec = customId ? ttsVoices.getVoice(customId) : null;
|
|
1300
|
-
if (!rec && !rawVoice) {
|
|
1301
|
-
const saved = ttsVoices.listVoices();
|
|
1302
|
-
if (saved.length) { customId = saved[0].id; rec = ttsVoices.getVoice(customId); }
|
|
1303
|
-
// Pocket can fall back to a built-in speaker; SpeechT5 has none, so for
|
|
1304
|
-
// that one "no saved voice" really is the end of the road.
|
|
1305
|
-
else if (ttsEngine.isPocket()) { useVoice = DEFAULT_POCKET_VOICE; customId = null; }
|
|
1306
|
-
}
|
|
1307
|
-
if (!rec && !customId && ttsEngine.isPocket()) {
|
|
1308
|
-
// resolved to a built-in above — nothing more to look up
|
|
1309
|
-
} else if (!rec) {
|
|
1310
|
-
return sendJson(res, customId ? 404 : 400, {
|
|
1311
|
-
error: {
|
|
1312
|
-
message: customId ? 'no such saved voice' : 'this model speaks in a voice you record — add one in Settings → Text-to-speech',
|
|
1313
|
-
type: 'bad_voice',
|
|
1314
|
-
},
|
|
1315
|
-
});
|
|
1316
|
-
}
|
|
1317
|
-
// Which print to hand over depends on the engine: Pocket TTS takes its
|
|
1318
|
-
// Mimi conditioning, SpeechT5 the 512-d x-vector. A voice saved before
|
|
1319
|
-
// the pocket bundle existed has only the latter.
|
|
1320
|
-
if (ttsEngine.isPocket()) {
|
|
1321
|
-
const pk = ttsVoices.getPocketVoice(customId);
|
|
1322
|
-
if (!pk) {
|
|
1323
|
-
return sendJson(res, 409, {
|
|
1324
|
-
error: {
|
|
1325
|
-
message: 'this voice was saved without a Pocket TTS conditioning — record it again with Pocket TTS selected',
|
|
1326
|
-
type: 'voice_kind_missing',
|
|
1327
|
-
},
|
|
1328
|
-
});
|
|
1329
|
-
}
|
|
1330
|
-
speakerEmbedding = pk;
|
|
1331
|
-
} else {
|
|
1332
|
-
speakerEmbedding = rec.vec;
|
|
1333
|
-
}
|
|
1334
|
-
useVoice = `custom:${customId}`;
|
|
1335
|
-
} else if (customId) {
|
|
1336
|
-
// Explicitly asked for a recorded voice this model cannot use — say so.
|
|
1337
|
-
// Inherited from config, though, it is just a stale setting, and refusing
|
|
1338
|
-
// to speak at all is a worse answer than speaking in the default voice.
|
|
1339
|
-
if (rawVoice) {
|
|
1340
|
-
return sendJson(res, 409, {
|
|
1341
|
-
error: { message: `the active model (${ttsEngine.arch()}) cannot use a recorded voice — switch to SpeechT5`, type: 'voice_unsupported' },
|
|
1342
|
-
});
|
|
1343
|
-
}
|
|
1344
|
-
customId = null;
|
|
1345
|
-
useVoice = DEFAULT_TTS_VOICE;
|
|
1346
|
-
} else if (ttsEngine.isPocket() && !isPocketVoice(useVoice)) {
|
|
1347
|
-
// Pocket has its own speaker namespace; a Kokoro voice name here means the
|
|
1348
|
-
// config was carried over from another model, so fall back to its default.
|
|
1349
|
-
useVoice = DEFAULT_POCKET_VOICE;
|
|
1350
|
-
} else if (ttsEngine.supportsVoices() && !ttsEngine.isPocket() && !(isKnownVoice(useVoice) && isValidVoiceId(useVoice))) {
|
|
1351
|
-
return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
|
|
1352
|
-
}
|
|
1291
|
+
const picked = resolveTtsVoice({
|
|
1292
|
+
requested: rawVoice, configured: voice, engine: ttsEngine, voices: ttsVoices,
|
|
1293
|
+
isPocketVoice, isKnownVoice, isValidVoiceId,
|
|
1294
|
+
defaultVoice: DEFAULT_TTS_VOICE, defaultPocketVoice: DEFAULT_POCKET_VOICE,
|
|
1295
|
+
});
|
|
1296
|
+
if (!picked.ok) return sendJson(res, picked.status, { error: { message: picked.message, type: picked.type } });
|
|
1297
|
+
const { voice: useVoice, customId, speakerEmbedding } = picked;
|
|
1353
1298
|
|
|
1354
1299
|
const pcm = await ttsEngine.synth(text, { voice: useVoice, speed, speakerEmbedding });
|
|
1355
1300
|
// The ACTIVE model's rate, not the constant: a VITS/MMS model emits 16 kHz
|
|
@@ -1361,7 +1306,9 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1361
1306
|
'Content-Length': String(out.length),
|
|
1362
1307
|
'Cache-Control': 'no-store',
|
|
1363
1308
|
'X-Tts-Sample-Rate': String(rate),
|
|
1364
|
-
|
|
1309
|
+
// Say which voice actually spoke, so a caller (or a person debugging one)
|
|
1310
|
+
// can check the setting took. Single-speaker models have nothing to say.
|
|
1311
|
+
...(customId || ttsEngine.isPocket() || ttsEngine.supportsVoices() ? { 'X-Tts-Voice': useVoice } : {}),
|
|
1365
1312
|
});
|
|
1366
1313
|
return res.end(out);
|
|
1367
1314
|
} catch (e) {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Which voice a /tts request actually speaks in — decided in ONE place, with every
|
|
2
|
+
// dependency injected so the table below can be tested without loading a model.
|
|
3
|
+
//
|
|
4
|
+
// The rule that shapes this: a saved clone is a CHOICE, never a default. Someone's
|
|
5
|
+
// own voice print must not start speaking because the config was empty, named a
|
|
6
|
+
// voice from another model, or pointed at a voice since deleted. A model with
|
|
7
|
+
// built-in speakers (Pocket) falls back to one of those; only SpeechT5, which has
|
|
8
|
+
// no built-ins, has nothing to fall back to but a recording.
|
|
9
|
+
//
|
|
10
|
+
// A voice named EXPLICITLY in the request never falls back — it succeeds or fails
|
|
11
|
+
// loudly, so a caller that asked for a specific voice can trust what it got.
|
|
12
|
+
//
|
|
13
|
+
// Returns either { ok: true, voice, customId, speakerEmbedding }
|
|
14
|
+
// or { ok: false, status, type, message }.
|
|
15
|
+
|
|
16
|
+
export function resolveTtsVoice({
|
|
17
|
+
requested = null, // body.voice, or null when the caller left it to the config
|
|
18
|
+
configured, // the voice the request would use absent an override
|
|
19
|
+
engine, // { isPocket(), supportsCustomVoices(), supportsVoices(), arch() }
|
|
20
|
+
voices, // { parseCustomVoice, getVoice, listVoices, getPocketVoice }
|
|
21
|
+
isPocketVoice,
|
|
22
|
+
isKnownVoice,
|
|
23
|
+
isValidVoiceId,
|
|
24
|
+
defaultVoice, // Kokoro's default
|
|
25
|
+
defaultPocketVoice, // Pocket's built-in default
|
|
26
|
+
}) {
|
|
27
|
+
const voice = requested || configured;
|
|
28
|
+
const pocket = engine.isPocket();
|
|
29
|
+
let customId = voices.parseCustomVoice(voice);
|
|
30
|
+
|
|
31
|
+
// A Pocket built-in speaker is a NAME, not an embedding, so it is recognised
|
|
32
|
+
// before the custom-voice path — which would otherwise demand a recording for a
|
|
33
|
+
// model that ships eight voices of its own.
|
|
34
|
+
if (pocket && isPocketVoice(voice)) return { ok: true, voice, customId: null, speakerEmbedding: null };
|
|
35
|
+
|
|
36
|
+
if (engine.supportsCustomVoices()) {
|
|
37
|
+
let rec = customId ? voices.getVoice(customId) : null;
|
|
38
|
+
if (!rec && !requested) {
|
|
39
|
+
// Nothing valid was configured and nothing was asked for.
|
|
40
|
+
if (pocket) return { ok: true, voice: defaultPocketVoice, customId: null, speakerEmbedding: null };
|
|
41
|
+
const saved = voices.listVoices();
|
|
42
|
+
if (saved.length) { customId = saved[0].id; rec = voices.getVoice(customId); }
|
|
43
|
+
}
|
|
44
|
+
if (!rec) {
|
|
45
|
+
return customId
|
|
46
|
+
? { ok: false, status: 404, type: 'bad_voice', message: 'no such saved voice' }
|
|
47
|
+
: { ok: false, status: 400, type: 'bad_voice', message: 'this model speaks in a voice you record — add one in Settings → Text-to-speech' };
|
|
48
|
+
}
|
|
49
|
+
// Which print to hand over depends on the engine: Pocket TTS takes its Mimi
|
|
50
|
+
// conditioning, SpeechT5 the 512-d x-vector. A voice saved before the pocket
|
|
51
|
+
// bundle existed has only the latter.
|
|
52
|
+
let speakerEmbedding;
|
|
53
|
+
if (pocket) {
|
|
54
|
+
speakerEmbedding = voices.getPocketVoice(customId);
|
|
55
|
+
if (!speakerEmbedding) {
|
|
56
|
+
return { ok: false, status: 409, type: 'voice_kind_missing', message: 'this voice was saved without a Pocket TTS conditioning — record it again with Pocket TTS selected' };
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
speakerEmbedding = rec.vec;
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, voice: `custom:${customId}`, customId, speakerEmbedding };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (customId) {
|
|
65
|
+
// Explicitly asked for a recorded voice this model cannot use — say so.
|
|
66
|
+
// Inherited from config, though, it is just a stale setting, and refusing to
|
|
67
|
+
// speak at all is a worse answer than speaking in the default voice.
|
|
68
|
+
if (requested) {
|
|
69
|
+
return { ok: false, status: 409, type: 'voice_unsupported', message: `the active model (${engine.arch()}) cannot use a recorded voice — switch to SpeechT5` };
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, voice: defaultVoice, customId: null, speakerEmbedding: null };
|
|
72
|
+
}
|
|
73
|
+
if (pocket && !isPocketVoice(voice)) return { ok: true, voice: defaultPocketVoice, customId: null, speakerEmbedding: null };
|
|
74
|
+
if (engine.supportsVoices() && !pocket && !(isKnownVoice(voice) && isValidVoiceId(voice))) {
|
|
75
|
+
return { ok: false, status: 400, type: 'bad_voice', message: 'unknown or invalid voice' };
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, voice, customId: null, speakerEmbedding: null };
|
|
78
|
+
}
|