@chatpanel/gateway 0.6.57 → 0.6.59

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.57",
3
+ "version": "0.6.59",
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/ort.js CHANGED
@@ -40,8 +40,9 @@ export function getOrt() {
40
40
  const mod = await import('onnxruntime-node');
41
41
  return mod.InferenceSession ? mod : (mod.default || mod);
42
42
  }
43
- // Say what is actually wrong. The alternative is ORT's own message, which
44
- // describes a failed fetch and sends people looking for a corrupt download.
43
+
44
+ // Say what is actually wrong. ORT's own message describes a failed fetch and
45
+ // sends people looking for a corrupt download.
45
46
  throw new Error(
46
47
  'this model needs the native onnxruntime, which the standalone binary does not carry — '
47
48
  + 'install the npm gateway instead (npm i -g @chatpanel/gateway), or pick a model that runs on the bundled runtime',
package/src/server.js CHANGED
@@ -54,7 +54,7 @@ import * as openai from './openai.js';
54
54
  import * as responses from './responses.js';
55
55
  import * as anthropic from './anthropic.js';
56
56
 
57
- export const VERSION = '0.6.57';
57
+ export const VERSION = '0.6.59';
58
58
 
59
59
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
60
60
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -1117,7 +1117,26 @@ export function createGateway(cfg = loadConfig()) {
1117
1117
  try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { body = null; }
1118
1118
  const name = body && typeof body.name === 'string' ? body.name.trim() : '';
1119
1119
  const pcm = body && Array.isArray(body.pcm) ? body.pcm : null;
1120
- if (!name) return sendJson(res, 400, { error: { message: 'a name is required', type: 'bad_request' } });
1120
+ // An `id` means UPDATE an existing voice rather than create one. The id is
1121
+ // preserved either way, because `custom:<id>` is what the config and every
1122
+ // client hold — a rename or a re-record must not orphan those.
1123
+ const editId = body && typeof body.id === 'string' ? body.id.trim() : '';
1124
+ if (editId) {
1125
+ if (!ttsVoices.getVoice(editId)) return sendJson(res, 404, { error: { message: 'no such saved voice', type: 'bad_voice' } });
1126
+ // Rename only — no new audio, so the prints are left exactly as they are.
1127
+ if (!pcm) {
1128
+ if (!name) return sendJson(res, 400, { error: { message: 'a name is required', type: 'bad_request' } });
1129
+ try {
1130
+ return sendJson(res, 200, { ...ttsVoices.renameVoice(editId, name), usable: ttsEngine.supportsCustomVoices() });
1131
+ } catch (e) { return sendJson(res, 400, { error: { message: e.message, type: 'rename_failed' } }); }
1132
+ }
1133
+ // Re-record: same rules as a fresh take, then swap the prints in place.
1134
+ if (pcm.length < 16000) {
1135
+ return sendJson(res, 400, { error: { message: 'need at least 1 second of 16 kHz mono audio', type: 'sample_too_short' } });
1136
+ }
1137
+ } else if (!name) {
1138
+ return sendJson(res, 400, { error: { message: 'a name is required', type: 'bad_request' } });
1139
+ }
1121
1140
  if (!pcm || pcm.length < 16000) {
1122
1141
  // Under a second of audio produces an embedding dominated by whatever
1123
1142
  // noise happened to be in it, and the resulting voice is arbitrary.
@@ -1166,9 +1185,13 @@ export function createGateway(cfg = loadConfig()) {
1166
1185
  console.log(`[tts] pocket conditioning unavailable for this voice (${e.message})`);
1167
1186
  }
1168
1187
 
1169
- const saved = ttsVoices.saveVoice({ name, vec, pocket });
1170
- console.log(`[tts] saved custom voice "${saved.name}" (${saved.kinds.join(' + ')}, sample discarded)`);
1171
- return sendJson(res, 201, { ...saved, usable: ttsEngine.supportsCustomVoices() });
1188
+ const saved = editId
1189
+ ? ttsVoices.replaceVoice(editId, { vec, pocket })
1190
+ : ttsVoices.saveVoice({ name, vec, pocket });
1191
+ // A rename may ride along with a re-record, so apply it after the swap.
1192
+ const final = editId && name && name !== saved.name ? ttsVoices.renameVoice(editId, name) : saved;
1193
+ console.log(`[tts] ${editId ? 're-recorded' : 'saved'} custom voice "${final.name}" (${(saved.kinds || []).join(' + ')}, sample discarded)`);
1194
+ return sendJson(res, editId ? 200 : 201, { ...saved, ...final, usable: ttsEngine.supportsCustomVoices() });
1172
1195
  } catch (e) {
1173
1196
  return sendJson(res, 400, { error: { message: e.message, type: 'save_failed' } });
1174
1197
  }
@@ -1366,7 +1389,9 @@ export function createGateway(cfg = loadConfig()) {
1366
1389
  // same composable model as everything else: any stage, with or without).
1367
1390
  // `diarize: true` (+ optional `speakerLabel` to pin the mic channel to a
1368
1391
  // name) attaches a speaker to each final.
1369
- const { id } = sttEngine.createSession({ lang: body?.lang, redact: body?.redact === true, diarize: wantDiarize, speakerLabel: body?.speakerLabel });
1392
+ // `endSilenceMs` (additive) lets a voice conversation wait longer for a
1393
+ // sentence to finish than dictation into a text box needs to.
1394
+ const { id } = sttEngine.createSession({ lang: body?.lang, redact: body?.redact === true, diarize: wantDiarize, speakerLabel: body?.speakerLabel, endSilenceMs: body?.endSilenceMs });
1370
1395
  return sendJson(res, 201, { id, state: sttEngine.state() });
1371
1396
  } catch (e) {
1372
1397
  return sendJson(res, e.code === 'too_many_sessions' ? 429 : 500, { error: { message: e.message, type: e.code || 'stt_error' } });
package/src/stt-engine.js CHANGED
@@ -238,13 +238,26 @@ let _decodeChain = Promise.resolve(); // whisper is effectively single-threaded
238
238
  export function sessionCount() { return _sessions.size; }
239
239
 
240
240
  /** @param {{ lang?: string, redact?: boolean, diarize?: boolean, speakerLabel?: string }} [opts] */
241
- export function createSession({ lang, redact = false, diarize: diarizeOpt = false, speakerLabel = null } = {}) {
241
+ // How long a pause commits a segment. The default suits dictation into a text box,
242
+ // where a final just appends and a short pause costs nothing. In a VOICE
243
+ // conversation every final is SENT as a question, so a 700ms pause mid-thought
244
+ // ("I want to… um… go to Google") sends half a sentence. Callers may ask for more.
245
+ const END_SILENCE_MIN_MS = 300;
246
+ const END_SILENCE_MAX_MS = 3000;
247
+ export function clampEndSilence(ms) {
248
+ const n = Number(ms);
249
+ if (!Number.isFinite(n)) return SILENCE_FINAL_MS;
250
+ return Math.min(END_SILENCE_MAX_MS, Math.max(END_SILENCE_MIN_MS, Math.round(n)));
251
+ }
252
+
253
+ export function createSession({ lang, redact = false, diarize: diarizeOpt = false, speakerLabel = null, endSilenceMs = SILENCE_FINAL_MS } = {}) {
242
254
  if (_sessions.size >= MAX_SESSIONS) {
243
255
  const e = /** @type {Error & { code?: string }} */ (new Error('too many concurrent dictation sessions'));
244
256
  e.code = 'too_many_sessions'; throw e;
245
257
  }
246
258
  const s = {
247
259
  id: randomUUID(),
260
+ endSilenceMs: clampEndSilence(endSilenceMs),
248
261
  lang: typeof lang === 'string' && lang ? lang.slice(0, 12) : null,
249
262
  langTried: false, // language auto-detect runs once per session (multilingual models)
250
263
  // Opaque to this engine: the server applies the redaction hop to finals when
@@ -374,7 +387,7 @@ async function decodeSession(s, { flush = false } = {}) {
374
387
  s.lastDecodeAt = Date.now();
375
388
 
376
389
  const audio = concatBuffer(s);
377
- const tail = Math.round((SILENCE_FINAL_MS / 1000) * SAMPLE_RATE);
390
+ const tail = Math.round(((s.endSilenceMs || SILENCE_FINAL_MS) / 1000) * SAMPLE_RATE);
378
391
  const trailingQuiet = audio.length > tail && rms(audio, audio.length - tail) < SILENCE_RMS;
379
392
 
380
393
  // Nothing but room tone? Don't decode (whisper hallucinates on silence) and
package/src/tts-voices.js CHANGED
@@ -75,7 +75,7 @@ export function listVoices() {
75
75
  // in a settings page's JSON.
76
76
  if (v?.id && v?.name) {
77
77
  out.push({
78
- id: v.id, name: v.name, createdAt: v.createdAt || 0, dim: v.vec?.length || 0,
78
+ id: v.id, name: v.name, createdAt: v.createdAt || 0, updatedAt: v.updatedAt || 0, dim: v.vec?.length || 0,
79
79
  kinds: v.pocketShape ? [KIND_SPEECHT5, KIND_POCKET] : [KIND_SPEECHT5],
80
80
  });
81
81
  }
@@ -133,6 +133,53 @@ export function saveVoice({ name, vec, pocket = null }) {
133
133
  return { id: rec.id, name: rec.name, createdAt: rec.createdAt, dim: rec.vec.length, kinds: rec.pocketShape ? [KIND_SPEECHT5, KIND_POCKET] : [KIND_SPEECHT5] };
134
134
  }
135
135
 
136
+ /**
137
+ * Rename in place. The id is untouched on purpose: `custom:<id>` is what the
138
+ * gateway config and every client store, so a rename must not invalidate them.
139
+ */
140
+ export function renameVoice(id, name) {
141
+ const rec = getVoice(id);
142
+ if (!rec) return null;
143
+ const clean = String(name || '').trim().slice(0, MAX_NAME);
144
+ if (!clean) throw new Error('a name is required');
145
+ rec.name = clean;
146
+ rec.updatedAt = Date.now();
147
+ writeFileSync(fileFor(id), JSON.stringify(rec));
148
+ return { id, name: rec.name, createdAt: rec.createdAt || 0, updatedAt: rec.updatedAt, dim: rec.vec?.length || 0 };
149
+ }
150
+
151
+ /**
152
+ * Re-record: swap in prints derived from a NEW sample, keeping the id and name.
153
+ *
154
+ * Keeping the id is the whole point. A first take is often poor — too quiet, a
155
+ * cough, the wrong room — and the fix should be "record that again", not "delete
156
+ * it, record a new one, and go re-select it everywhere". Anything already pointing
157
+ * at this voice keeps working and simply sounds different.
158
+ */
159
+ export function replaceVoice(id, { vec, pocket = null }) {
160
+ const rec = getVoice(id);
161
+ if (!rec) return null;
162
+ if (!vec || vec.length !== EMBED_DIM) throw new Error(`expected a ${EMBED_DIM}-value embedding, got ${vec?.length || 0}`);
163
+ rec.vec = Array.from(vec, (x) => Number(x) || 0);
164
+ rec.updatedAt = Date.now();
165
+ if (pocket?.data?.length && Array.isArray(pocket.shape)) {
166
+ rec.pocketShape = pocket.shape;
167
+ const f32 = pocket.data instanceof Float32Array ? pocket.data : Float32Array.from(pocket.data);
168
+ writeFileSync(pocketFileFor(id), Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength));
169
+ } else if (rec.pocketShape) {
170
+ // The new take produced no Pocket conditioning (its bundle is missing), so the
171
+ // OLD one must go — leaving it would pair a stale voice with a fresh print and
172
+ // the voice would change depending on which engine spoke.
173
+ delete rec.pocketShape;
174
+ rmSync(pocketFileFor(id), { force: true });
175
+ }
176
+ writeFileSync(fileFor(id), JSON.stringify(rec));
177
+ return {
178
+ id, name: rec.name, createdAt: rec.createdAt || 0, updatedAt: rec.updatedAt,
179
+ dim: rec.vec.length, kinds: rec.pocketShape ? [KIND_SPEECHT5, KIND_POCKET] : [KIND_SPEECHT5],
180
+ };
181
+ }
182
+
136
183
  /** Remove one permanently. Returns whether there was anything to remove. */
137
184
  export function deleteVoice(id) {
138
185
  if (!isValidVoiceRef(id)) return false;