openvibers/openvibe.chat Commits

Agent Streams S4: the machine TTS route (#361)

OP OpenVibers committed 7 days ago · 2054a1311d · 3 files, +133 −0

* Agent Streams S4: the machine TTS route

The desk asks Chat for the sound of a line the agent said. The route takes a service token for audience
openvibe.chat that carries chat.tts.speak and names the person it acts for, so the voice and the allowance are that
person's. The text is one line (220 characters at most); the person's own voice is asked for by name and never
another person's.

Unlike Chat's /internal routes (loopback only), a desk is a machine on the person's own network, so this one route
takes a token from outside.

* Agent voice: at most 6 lines a minute and 900 a day per person (TTS spends Chat's budget), and a test for the route

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 8f1cd82..acf6b8f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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'));
ddiff --git a/server/chat/tts-machine.js b/server/chat/tts-machine.js
nnew file mode 100644
iindex 0000000..6f99b1a
---- /dev/null
++++ b/server/chat/tts-machine.js
@@@ -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;
ddiff --git a/test/agent-voice.test.js b/test/agent-voice.test.js
nnew file mode 100644
iindex 0000000..c1aebd8
---- /dev/null
++++ b/test/agent-voice.test.js
@@@ -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); });