@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 +1 -1
- package/src/ort.js +3 -2
- package/src/server.js +31 -6
- package/src/stt-engine.js +15 -2
- package/src/tts-voices.js +48 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
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
|
-
|
|
44
|
-
//
|
|
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
|
+
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
|
-
|
|
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 =
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|