openvibers/openvibe.chat Commits
Agent Streams S4: the machine TTS route (#361)
Changes
@@@ -307,6 +307,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {// Vibe from any OpenVibe site (the Dock, plan L48 V2): ask, and read the answer, with the person's own token.app.use('/api/vibe', require('./rooms/vibe-api'));app.use('/api/tts', require('./chat/tts-routes'));++ // The desk asks Chat for the sound of a line the agent said (Agent Streams S4; capability chat.tts.speak).++ app.use('/api/agent-voice', require('./chat/tts-machine'));app.use('/api/sounds', require('./chat/sounds-routes'));// Custom emotes (Live's /api/emotes paths, plan T3): bytes go to OpenVibe.Media, rows are Chat's.app.use('/api/emotes', require('./chat/emotes-routes'));nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++/**++ * OpenVibe.Chat — the machine TTS route (Agent Streams S4, design 2026-11-06).++ *++ * The desk (OpenVibe.Node) asks Chat for the sound of a line the agent said. The desk holds a node token for++ * audience openvibe.chat that carries the capability chat.tts.speak and names the person it acts for++ * (`on_behalf_of`, the Network's rule for a node token acting for its owner). Chat speaks in that person's voice and++ * on that person's allowance.++ *++ * POST /api/agent-voice/speak { text, voice?, own? } → { audio, mime_type, rate }++ *++ * The text is one line (the design's 220 characters at most). `voice` is a Chat voice id; `own` asks for the++ * person's own voice (their saved voice settings), never another person's.++ *++ * The route is public but not open: it takes a service token only. Unlike Chat's /internal routes (loopback only),++ * a desk is a machine on the person's own network, so this one route accepts a token from outside.++ */++const express = require('express');++const { verifyServiceToken } = require('openvibe-sdk/auth');++const contracts = require('openvibe-contracts');++const config = require('../config');++const { keys } = require('../net/service-auth');++const ttsEngine = require('./tts-engine');++++const { capabilities, http } = contracts;++const AUDIENCE = 'openvibe.chat';++const CAPABILITY = 'chat.tts.speak';++// The design's line length: one line, never a paragraph.++const MAX_CHARS = 220;++++const router = express.Router();++router.use(express.json({ limit: '8kb' }));++++/** The person the token names, or null: a node token acts for its owner (`on_behalf_of`), a person token is the person. */++function personOf(claims) {++ const onBehalf = String(claims.on_behalf_of || '');++ if (onBehalf) return onBehalf;++ const sub = String(claims.sub || '');++ return sub.startsWith('usr_') ? sub : null;++}++++/** The service token guard for this route: the capability, and a person the token speaks for. */++async function guard(req, res, next) {++ const auth = String(req.headers.authorization || '');++ if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token' });++ const r = await verifyServiceToken(auth.slice(7).trim(), { ...keys().verifyOptions, issuer: config.networkUrl, audience: AUDIENCE, contracts });++ if (!r.ok) {++ if (r.code === 'token.unavailable') return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded' });++ return http.sendProblem(res, 401, r.code || 'token.invalid', { detail: r.reason });++ }++ // The capability is not in the contracts registry yet, so the grant in the token is what is checked.++ const c = capabilities.get(CAPABILITY)++ ? capabilities.check(r.claims, CAPABILITY)++ : (capabilities.grants(r.claims.cap, CAPABILITY) ? { allowed: true } : { allowed: false });++ if (!c.allowed) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} not granted` });++ const person = personOf(r.claims);++ if (!person) return http.sendProblem(res, 403, 'subject.not_delegated', { detail: 'the token names nobody' });++ req.principal = { sub: r.claims.sub, cap: r.claims.cap, person };++ next();++}++++// Per person: the desk speaks at most one line every 20 s (Live paces it), so 6 a minute and 900 a day is room for a++// curtain-free stream and a ceiling for a leaked token (TTS spends Chat's own budget).++const PER_MINUTE = 6;++const PER_DAY = 900;++const spoken = new Map(); // person → { minute, minuteAt, day, dayAt }++function allow(person, now = Date.now()) {++ const e = spoken.get(person) || { minute: 0, minuteAt: now, day: 0, dayAt: now };++ if (now - e.minuteAt >= 60_000) { e.minute = 0; e.minuteAt = now; }++ if (now - e.dayAt >= 86_400_000) { e.day = 0; e.dayAt = now; }++ if (e.minute >= PER_MINUTE || e.day >= PER_DAY) { spoken.set(person, e); return false; }++ e.minute += 1; e.day += 1;++ spoken.set(person, e);++ if (spoken.size > 50_000) spoken.clear();++ return true;++}++++router.post('/speak', guard, async (req, res) => {++ try {++ if (!allow(req.principal.person)) {++ res.set('Retry-After', '20');++ return res.status(429).json({ error: 'Too many lines spoken right now', code: 'voice_rate_limited' });++ }++ const body = req.body || {};++ const text = String(body.text || '').trim().slice(0, MAX_CHARS);++ if (!text) return res.status(400).json({ error: 'text is required', code: 'text_required' });++ const own = !!body.own;++ const voice = typeof body.voice === 'string' ? body.voice : '';++ // The person's own voice comes from their own saved voice settings; a site voice comes from the catalogue.++ const result = own++ ? await ttsEngine.synthesizeUserVoice(text, req.principal.person, req.principal.person)++ : await ttsEngine.synthesize(text, voice || undefined, req.principal.person);++ if (!result) return res.status(503).json({ error: 'The voice is unavailable', code: 'voice_unavailable' });++ res.set('Cache-Control', 'no-store');++ res.json({ audio: result.audio, mime_type: result.mimeType, rate: 48000, voice_id: result.voiceId || voice || null });++ } catch (err) {++ console.error('[Chat] the agent voice failed:', err.message);++ res.status(500).json({ error: 'The line could not be spoken', code: 'tts_failed' });++ }++});++++module.exports = router;++module.exports._allow = allow;nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++// The agent voice route (Agent Streams S4, server/chat/tts-machine.js): no token is refused, and a person gets at most++// 6 lines a minute (the desk speaks one every 20 s; the cap is for a leaked token, since TTS spends Chat's budget).++const assert = require('assert');++const http = require('http');++const express = require('express');++++(async () => {++ const router = require('../server/chat/tts-machine');++ const allow = router._allow;++ const t0 = 1_000_000;++ for (let i = 0; i < 6; i++) assert.strictEqual(allow('usr_A', t0 + i), true, `line ${i + 1} is spoken`);++ assert.strictEqual(allow('usr_A', t0 + 10), false, 'the seventh in a minute is refused');++ assert.strictEqual(allow('usr_B', t0 + 10), true, 'another person has their own count');++ assert.strictEqual(allow('usr_A', t0 + 61_000), true, 'a minute later it speaks again');++++ const app = express();++ app.use('/api/agent-voice', router);++ const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });++ try {++ const r = await fetch(`http://127.0.0.1:${server.address().port}/api/agent-voice/speak`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"text":"hello"}' });++ assert.strictEqual(r.status, 401, 'no token, no voice');++ const bad = await fetch(`http://127.0.0.1:${server.address().port}/api/agent-voice/speak`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer not-a-token' }, body: '{"text":"hello"}' });++ assert.ok([401, 503].includes(bad.status), 'a bad token, no voice (503 when the keys cannot be read: closed, never open)');++ } finally { server.close(); }++ console.log('agent voice: tokens required, six lines a minute per person');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -307,6 +307,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -307,6 +307,8 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
// Vibe from any OpenVibe site (the Dock, plan L48 V2): ask, and read the answer, with the person's own token.
// Vibe from any OpenVibe site (the Dock, plan L48 V2): ask, and read the answer, with the person's own token.
app.use('/api/vibe', require('./rooms/vibe-api'));
app.use('/api/vibe', require('./rooms/vibe-api'));
app.use('/api/tts', require('./chat/tts-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
+ // The desk asks Chat for the sound of a line the agent said (Agent Streams S4; capability chat.tts.speak).
+ app.use('/api/agent-voice', require('./chat/tts-machine'));
app.use('/api/sounds', require('./chat/sounds-routes'));
app.use('/api/sounds', require('./chat/sounds-routes'));
// Custom emotes (Live's /api/emotes paths, plan T3): bytes go to OpenVibe.Media, rows are Chat's.
// Custom emotes (Live's /api/emotes paths, plan T3): bytes go to OpenVibe.Media, rows are Chat's.
app.use('/api/emotes', require('./chat/emotes-routes'));
app.use('/api/emotes', require('./chat/emotes-routes'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+/**
+ * OpenVibe.Chat — the machine TTS route (Agent Streams S4, design 2026-11-06).
+ *
+ * The desk (OpenVibe.Node) asks Chat for the sound of a line the agent said. The desk holds a node token for
+ * audience openvibe.chat that carries the capability chat.tts.speak and names the person it acts for
+ * (`on_behalf_of`, the Network's rule for a node token acting for its owner). Chat speaks in that person's voice and
+ * on that person's allowance.
+ *
+ * POST /api/agent-voice/speak { text, voice?, own? } → { audio, mime_type, rate }
+ *
+ * The text is one line (the design's 220 characters at most). `voice` is a Chat voice id; `own` asks for the
+ * person's own voice (their saved voice settings), never another person's.
+ *
+ * The route is public but not open: it takes a service token only. Unlike Chat's /internal routes (loopback only),
+ * a desk is a machine on the person's own network, so this one route accepts a token from outside.
+ */
+const express = require('express');
+const { verifyServiceToken } = require('openvibe-sdk/auth');
+const contracts = require('openvibe-contracts');
+const config = require('../config');
+const { keys } = require('../net/service-auth');
+const ttsEngine = require('./tts-engine');
+
+const { capabilities, http } = contracts;
+const AUDIENCE = 'openvibe.chat';
+const CAPABILITY = 'chat.tts.speak';
+// The design's line length: one line, never a paragraph.
+const MAX_CHARS = 220;
+
+const router = express.Router();
+router.use(express.json({ limit: '8kb' }));
+
+/** The person the token names, or null: a node token acts for its owner (`on_behalf_of`), a person token is the person. */
+function personOf(claims) {
+ const onBehalf = String(claims.on_behalf_of || '');
+ if (onBehalf) return onBehalf;
+ const sub = String(claims.sub || '');
+ return sub.startsWith('usr_') ? sub : null;
+}
+
+/** The service token guard for this route: the capability, and a person the token speaks for. */
+async function guard(req, res, next) {
+ const auth = String(req.headers.authorization || '');
+ if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'no service token' });
+ const r = await verifyServiceToken(auth.slice(7).trim(), { ...keys().verifyOptions, issuer: config.networkUrl, audience: AUDIENCE, contracts });
+ if (!r.ok) {
+ if (r.code === 'token.unavailable') return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded' });
+ return http.sendProblem(res, 401, r.code || 'token.invalid', { detail: r.reason });
+ }
+ // The capability is not in the contracts registry yet, so the grant in the token is what is checked.
+ const c = capabilities.get(CAPABILITY)
+ ? capabilities.check(r.claims, CAPABILITY)
+ : (capabilities.grants(r.claims.cap, CAPABILITY) ? { allowed: true } : { allowed: false });
+ if (!c.allowed) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} not granted` });
+ const person = personOf(r.claims);
+ if (!person) return http.sendProblem(res, 403, 'subject.not_delegated', { detail: 'the token names nobody' });
+ req.principal = { sub: r.claims.sub, cap: r.claims.cap, person };
+ next();
+}
+
+// Per person: the desk speaks at most one line every 20 s (Live paces it), so 6 a minute and 900 a day is room for a
+// curtain-free stream and a ceiling for a leaked token (TTS spends Chat's own budget).
+const PER_MINUTE = 6;
+const PER_DAY = 900;
+const spoken = new Map(); // person → { minute, minuteAt, day, dayAt }
+function allow(person, now = Date.now()) {
+ const e = spoken.get(person) || { minute: 0, minuteAt: now, day: 0, dayAt: now };
+ if (now - e.minuteAt >= 60_000) { e.minute = 0; e.minuteAt = now; }
+ if (now - e.dayAt >= 86_400_000) { e.day = 0; e.dayAt = now; }
+ if (e.minute >= PER_MINUTE || e.day >= PER_DAY) { spoken.set(person, e); return false; }
+ e.minute += 1; e.day += 1;
+ spoken.set(person, e);
+ if (spoken.size > 50_000) spoken.clear();
+ return true;
+}
+
+router.post('/speak', guard, async (req, res) => {
+ try {
+ if (!allow(req.principal.person)) {
+ res.set('Retry-After', '20');
+ return res.status(429).json({ error: 'Too many lines spoken right now', code: 'voice_rate_limited' });
+ }
+ const body = req.body || {};
+ const text = String(body.text || '').trim().slice(0, MAX_CHARS);
+ if (!text) return res.status(400).json({ error: 'text is required', code: 'text_required' });
+ const own = !!body.own;
+ const voice = typeof body.voice === 'string' ? body.voice : '';
+ // The person's own voice comes from their own saved voice settings; a site voice comes from the catalogue.
+ const result = own
+ ? await ttsEngine.synthesizeUserVoice(text, req.principal.person, req.principal.person)
+ : await ttsEngine.synthesize(text, voice || undefined, req.principal.person);
+ if (!result) return res.status(503).json({ error: 'The voice is unavailable', code: 'voice_unavailable' });
+ res.set('Cache-Control', 'no-store');
+ res.json({ audio: result.audio, mime_type: result.mimeType, rate: 48000, voice_id: result.voiceId || voice || null });
+ } catch (err) {
+ console.error('[Chat] the agent voice failed:', err.message);
+ res.status(500).json({ error: 'The line could not be spoken', code: 'tts_failed' });
+ }
+});
+
+module.exports = router;
+module.exports._allow = allow;
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+// The agent voice route (Agent Streams S4, server/chat/tts-machine.js): no token is refused, and a person gets at most
+// 6 lines a minute (the desk speaks one every 20 s; the cap is for a leaked token, since TTS spends Chat's budget).
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+
+(async () => {
+ const router = require('../server/chat/tts-machine');
+ const allow = router._allow;
+ const t0 = 1_000_000;
+ for (let i = 0; i < 6; i++) assert.strictEqual(allow('usr_A', t0 + i), true, `line ${i + 1} is spoken`);
+ assert.strictEqual(allow('usr_A', t0 + 10), false, 'the seventh in a minute is refused');
+ assert.strictEqual(allow('usr_B', t0 + 10), true, 'another person has their own count');
+ assert.strictEqual(allow('usr_A', t0 + 61_000), true, 'a minute later it speaks again');
+
+ const app = express();
+ app.use('/api/agent-voice', router);
+ const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });
+ try {
+ const r = await fetch(`http://127.0.0.1:${server.address().port}/api/agent-voice/speak`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"text":"hello"}' });
+ assert.strictEqual(r.status, 401, 'no token, no voice');
+ const bad = await fetch(`http://127.0.0.1:${server.address().port}/api/agent-voice/speak`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer not-a-token' }, body: '{"text":"hello"}' });
+ assert.ok([401, 503].includes(bad.status), 'a bad token, no voice (503 when the keys cannot be read: closed, never open)');
+ } finally { server.close(); }
+ console.log('agent voice: tokens required, six lines a minute per person');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });