@chatpanel/gateway 0.6.54 → 0.6.55

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +83 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.54",
3
+ "version": "0.6.55",
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
@@ -40,7 +40,7 @@ import * as diarizeEngine from './diarize-engine.js';
40
40
  import { MODEL_CATALOG, isKnownModel, isValidCustomModelId } from './models.js';
41
41
  import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MODEL, STT_DTYPES, isValidDtype } from './stt-models.js';
42
42
  import * as ttsEngine from './tts-engine.js';
43
- import { TTS_MODEL_CATALOG, TTS_VOICES, isKnownTtsModel, isValidCustomTtsId, isKnownVoice, isValidVoiceId, DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE, TTS_DTYPES, isValidTtsDtype, MAX_TTS_CHARS } from './tts-models.js';
43
+ import { TTS_MODEL_CATALOG, TTS_VOICES, isKnownTtsModel, isValidCustomTtsId, isKnownVoice, isValidVoiceId, DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE, TTS_DTYPES, isValidTtsDtype, MAX_TTS_CHARS, ttsModelHasCustomVoices } from './tts-models.js';
44
44
  import { ttsDestination, synthesizeRemote, isValidRemoteVoice } from './tts-remote.js';
45
45
  import * as ttsVoices from './tts-voices.js';
46
46
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
@@ -53,7 +53,7 @@ import * as openai from './openai.js';
53
53
  import * as responses from './responses.js';
54
54
  import * as anthropic from './anthropic.js';
55
55
 
56
- export const VERSION = '0.6.54';
56
+ export const VERSION = '0.6.55';
57
57
 
58
58
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
59
59
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -1030,6 +1030,26 @@ export function createGateway(cfg = loadConfig()) {
1030
1030
  if (!cfg.tts) cfg.tts = { enabled: true, model: DEFAULT_TTS_MODEL, voice: DEFAULT_TTS_VOICE, allowDownload: true };
1031
1031
  if (id) cfg.tts.model = id;
1032
1032
  if (voice) cfg.tts.voice = voice;
1033
+ // Switching model must revalidate the voice, or the config ends up naming a
1034
+ // Kokoro voice for SpeechT5 (which then has nothing to speak in) or a
1035
+ // recorded voice for Kokoro (which cannot use one). Both states look like
1036
+ // "text-to-speech is broken" from the outside, and both are reachable with
1037
+ // two clicks. Only rewrite when the CURRENT voice cannot work for the new
1038
+ // model — never override a voice the caller just set.
1039
+ if (id && !voice) {
1040
+ const wantsCustom = ttsModelHasCustomVoices(id);
1041
+ // "Is it a custom voice" is not enough — it must be one that still
1042
+ // EXISTS. A config naming a deleted voice is exactly the state that made
1043
+ // every later request fail with "no such saved voice".
1044
+ const curId = ttsVoices.parseCustomVoice(cfg.tts.voice || '');
1045
+ const isCustom = !!(curId && ttsVoices.getVoice(curId));
1046
+ if (wantsCustom && !isCustom) {
1047
+ const saved = ttsVoices.listVoices();
1048
+ cfg.tts.voice = saved.length ? `custom:${saved[0].id}` : '';
1049
+ } else if (!wantsCustom && isCustom) {
1050
+ cfg.tts.voice = DEFAULT_TTS_VOICE;
1051
+ }
1052
+ }
1033
1053
  if (dtype) cfg.tts.dtype = dtype === 'auto' ? null : dtype;
1034
1054
  try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
1035
1055
  if (id) ttsEngine.setModel(id, { onLog: (m) => console.log(m), dtype: dtype || cfg.tts.dtype || 'auto' });
@@ -1101,7 +1121,16 @@ export function createGateway(cfg = loadConfig()) {
1101
1121
  if (req.method === 'DELETE') {
1102
1122
  const id = url.searchParams.get('id') || '';
1103
1123
  // Deleting someone's voice print is not a soft delete — the file is gone.
1104
- return sendJson(res, 200, { deleted: ttsVoices.deleteVoice(id) });
1124
+ const deleted = ttsVoices.deleteVoice(id);
1125
+ // …and the config must not keep NAMING it. A stored voice that no longer
1126
+ // exists makes every later request fail with "no such saved voice", which
1127
+ // is a confusing way to be told "you deleted that one".
1128
+ if (deleted && cfg.tts?.voice === `custom:${id}`) {
1129
+ const left = ttsVoices.listVoices();
1130
+ cfg.tts.voice = left.length ? `custom:${left[0].id}` : DEFAULT_TTS_VOICE;
1131
+ try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
1132
+ }
1133
+ return sendJson(res, 200, { deleted, voice: cfg.tts?.voice });
1105
1134
  }
1106
1135
  }
1107
1136
 
@@ -1128,32 +1157,12 @@ export function createGateway(cfg = loadConfig()) {
1128
1157
  // not a Kokoro one), so the local catalog check would reject every valid id.
1129
1158
  const rawVoice = body && typeof body.voice === 'string' && body.voice.trim() ? body.voice.trim() : null;
1130
1159
  const voice = rawVoice || (dest ? dest.voice : (cfg.tts?.voice || DEFAULT_TTS_VOICE));
1131
- // `custom:<id>` names a saved voice. It is resolved to an embedding here so
1132
- // the engine never has to know where voices are stored.
1133
- let customId = dest ? null : ttsVoices.parseCustomVoice(voice);
1134
- // The active model may REQUIRE an embedding while the configured voice still
1135
- // names a built-in one switching to SpeechT5 does not rewrite `voice`, and
1136
- // a client that never sends one inherits whatever was there. Rather than
1137
- // failing with "record a voice" at someone who already has, fall back to the
1138
- // most recent saved voice. Erroring is right only when there is genuinely
1139
- // none to use.
1140
- if (!dest && !customId && ttsEngine.supportsCustomVoices()) {
1141
- const saved = ttsVoices.listVoices();
1142
- if (saved.length) customId = saved[0].id;
1160
+ // A REMOTE destination has its own voice namespace, so it is validated here;
1161
+ // local voices cannot be resolved until the model is loaded, because which
1162
+ // KIND of voice is valid depends on the architecture. See below.
1163
+ if (dest && !isValidRemoteVoice(voice)) {
1164
+ return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
1143
1165
  }
1144
- let speakerEmbedding = null;
1145
- if (customId) {
1146
- const rec = ttsVoices.getVoice(customId);
1147
- if (!rec) return sendJson(res, 404, { error: { message: 'no such saved voice', type: 'bad_voice' } });
1148
- if (!ttsEngine.supportsCustomVoices() && ttsEngine.isReady()) {
1149
- return sendJson(res, 409, { error: { message: `the active model (${ttsEngine.arch()}) cannot use a recorded voice — switch to SpeechT5`, type: 'voice_unsupported' } });
1150
- }
1151
- speakerEmbedding = rec.vec;
1152
- }
1153
- const voiceOk = customId ? true
1154
- : dest ? isValidRemoteVoice(voice)
1155
- : (isKnownVoice(voice) && isValidVoiceId(voice));
1156
- if (!voiceOk) return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
1157
1166
  // We synthesize WAV only. Say so rather than returning WAV bytes under an mp3
1158
1167
  // content-type — a client that trusts the header would play noise.
1159
1168
  const fmt = body && typeof body.response_format === 'string' ? body.response_format.toLowerCase() : 'wav';
@@ -1188,7 +1197,52 @@ export function createGateway(cfg = loadConfig()) {
1188
1197
  dtype: cfg.tts?.dtype || 'auto',
1189
1198
  });
1190
1199
  if (!ok) return sendJson(res, 503, { error: { message: ttsEngine.health().error || 'tts model not ready', type: 'tts_unavailable' } });
1191
- const pcm = await ttsEngine.synth(text, { voice, speed, speakerEmbedding });
1200
+ // Voices are resolved AFTER the model is up, because what counts as a valid
1201
+ // voice is a property of the architecture: Kokoro takes a style-bank name,
1202
+ // SpeechT5 takes a recorded embedding, VITS takes neither. Resolving first
1203
+ // meant a `custom:` voice could reach a freshly-loaded Kokoro and fail deep
1204
+ // in the engine with "invalid voice id".
1205
+ let useVoice = voice;
1206
+ let speakerEmbedding = null;
1207
+ let customId = ttsVoices.parseCustomVoice(voice);
1208
+
1209
+ if (ttsEngine.supportsCustomVoices()) {
1210
+ // This model speaks ONLY in a recorded voice. If the configured one names
1211
+ // a built-in (switching model does not rewrite `voice`) or points at a
1212
+ // voice since deleted, fall back to the most recent saved one — the
1213
+ // caller asked to be spoken to, not for that exact voice. A voice named
1214
+ // EXPLICITLY in the request still fails loudly.
1215
+ let rec = customId ? ttsVoices.getVoice(customId) : null;
1216
+ if (!rec && !rawVoice) {
1217
+ const saved = ttsVoices.listVoices();
1218
+ if (saved.length) { customId = saved[0].id; rec = ttsVoices.getVoice(customId); }
1219
+ }
1220
+ if (!rec) {
1221
+ return sendJson(res, customId ? 404 : 400, {
1222
+ error: {
1223
+ message: customId ? 'no such saved voice' : 'this model speaks in a voice you record — add one in Settings → Text-to-speech',
1224
+ type: 'bad_voice',
1225
+ },
1226
+ });
1227
+ }
1228
+ speakerEmbedding = rec.vec;
1229
+ useVoice = `custom:${customId}`;
1230
+ } else if (customId) {
1231
+ // Explicitly asked for a recorded voice this model cannot use — say so.
1232
+ // Inherited from config, though, it is just a stale setting, and refusing
1233
+ // to speak at all is a worse answer than speaking in the default voice.
1234
+ if (rawVoice) {
1235
+ return sendJson(res, 409, {
1236
+ error: { message: `the active model (${ttsEngine.arch()}) cannot use a recorded voice — switch to SpeechT5`, type: 'voice_unsupported' },
1237
+ });
1238
+ }
1239
+ customId = null;
1240
+ useVoice = DEFAULT_TTS_VOICE;
1241
+ } else if (ttsEngine.supportsVoices() && !(isKnownVoice(useVoice) && isValidVoiceId(useVoice))) {
1242
+ return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
1243
+ }
1244
+
1245
+ const pcm = await ttsEngine.synth(text, { voice: useVoice, speed, speakerEmbedding });
1192
1246
  // The ACTIVE model's rate, not the constant: a VITS/MMS model emits 16 kHz
1193
1247
  // and writing it into a 24 kHz header plays it fast and chipmunked.
1194
1248
  const rate = ttsEngine.sampleRate();