openvibers/openvibe.chat Commits

Companions S6: the voice route a person calls with their own token (#370)

OP OpenVibers committed 7 days ago · 6c47b861a1 · 3 files, +155 −0

* Companions S6: the voice route a person calls with their own token

A companion's answer is read aloud in the character's own voice. The sound comes from the same TTS engine the agent voice uses, called with the person's own Network token, so the read-aloud is on the person's own allowance and on the same per-person limiter (six lines a minute, 900 a day) as the machine route. The route is for the person themself: an API token or a Dock token is refused. Only the site's own voices are used here, never a cloned one. The text is one line, the 220 characters the chat.tts-speak-request contract caps it at.

* Merge remote-tracking branch 'origin/main' into feature/companions-s5-s6-chat

* The voice route guards its own path only

The router is mounted under /api/tts, and it guarded every route there: the public voice catalogue, the settings and the stashed clips answered 401 when the person route went in. The guard belongs to POST /speak alone; the other routes under /api/tts stay public, as they were.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 1d20abe..7e06c8e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -318,6 +318,9 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
app.use('/api/dm', require('./chat/dm-routes'));
// 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'));
++ // A companion's answer, read aloud in the character's own voice (the person's own token, the agent
++ // voice's per-person limiter).
++ app.use('/api/tts', require('./chat/tts-person'));
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'));
ddiff --git a/server/chat/tts-person.js b/server/chat/tts-person.js
nnew file mode 100644
iindex 0000000..23543e5
---- /dev/null
++++ b/server/chat/tts-person.js
@@@ -0,0 +1,65 @@
++'use strict';
++/**
++ * The person's own voice (Opus's design, 2026-11-06, step 6): a companion's answer read aloud in the
++ * character's own voice.
++ *
++ * POST /api/tts/speak { text, voice } → { audio, mime_type, rate, voice_id }
++ *
++ * The person themself, with their own Network token - the same way the Dock reaches /api/vibe - so the
++ * read-aloud spends their own allowance and their own rate limit. The site's own voices only: a cloned
++ * voice is never used here (a cloned voice needs its owner's opt-in, and that is a separate thing).
++ *
++ * The text is one line: the contract's chat.tts-speak-request@1 caps it at 220 characters, and the
++ * contract says so for this route too. A longer answer is spoken as its first line.
++ */
++const express = require('express');
++const { requireAuth } = require('../auth/auth');
++const ttsEngine = require('./tts-engine');
++// The per-person limiter is the agent voice's own (server/chat/tts-machine.js): one counter per person,
++// six a minute and 900 a day, so the desk and the companions cannot double the allowance.
++const allow = require('./tts-machine')._allow;
++
++// The design's line length: one line, never a paragraph (chat.tts-speak-request@1).
++const MAX_CHARS = 220;
++
++const router = express.Router();
++router.use(express.json({ limit: '8kb' }));
++// The guard belongs to this route alone. The router is mounted under /api/tts, whose other routes (the voice
++// catalogue, the settings, the stashed clips) are public and must stay that way.
++
++/** The voice is for the person themself: an API token or a Dock token is refused. */
++const personOnly = (req, res, next) => (req.authSource === 'api_token' || req.authSource === 'dock'
++ ? res.status(403).json({ error: 'The voice is for the person themself', code: 'tts.person_only' })
++ : next());
++
++/** The person the rate limit counts: their Network subject (usr_...), the same id the agent voice counts. */
++function personOf(req) {
++ if (req.user && req.user.subject_id) return String(req.user.subject_id);
++ if (req.user && req.user.id != null) return 'user:' + String(req.user.id);
++ return 'anonymous';
++}
++
++router.post('/speak', requireAuth, personOnly, async (req, res) => {
++ try {
++ const person = personOf(req);
++ if (!allow(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 voice = typeof body.voice === 'string' ? body.voice : '';
++ const result = await ttsEngine.synthesize(text, voice || undefined, 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 companion 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;
++module.exports._personOf = personOf;
ddiff --git a/test/tts-person.test.js b/test/tts-person.test.js
nnew file mode 100644
iindex 0000000..1f20a18
---- /dev/null
++++ b/test/tts-person.test.js
@@@ -0,0 +1,87 @@
++'use strict';
++/**
++ * The person's own voice (server/chat/tts-person.js): the route is for the person themself, it speaks in
++ * the voice it is given, it speaks one line (the contract's 220 characters), and it is on the same
++ * per-person limiter as the agent voice: six lines a minute, then the route refuses.
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++
++// The account and the engine are stood in for, so the test never needs the Network or a speech provider.
++const authPath = require.resolve('../server/auth/auth');
++require.cache[authPath] = {
++ id: authPath, filename: authPath, loaded: true, children: [], paths: [],
++ exports: {
++ requireAuth: (req, res, next) => {
++ if (!req.headers['x-test-person']) return res.status(401).json({ error: 'Authentication required' });
++ req.user = { id: 7, subject_id: String(req.headers['x-test-subject'] || 'usr_TESTONE') };
++ req.authSource = String(req.headers['x-test-source'] || 'network');
++ next();
++ },
++ },
++};
++const calls = [];
++const enginePath = require.resolve('../server/chat/tts-engine');
++require.cache[enginePath] = {
++ id: enginePath, filename: enginePath, loaded: true, children: [], paths: [],
++ exports: {
++ synthesize: async (text, voice, who) => {
++ calls.push({ text, voice, who });
++ return { audio: 'QUFB', mimeType: 'audio/mpeg', voiceId: voice || 'default' };
++ },
++ },
++};
++
++(async () => {
++ const router = require('../server/chat/tts-person');
++ const app = express();
++ app.use('/api/tts', router);
++ const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });
++ const base = 'http://127.0.0.1:' + server.address().port;
++ const say = async (body, headers = {}) => {
++ const res = await fetch(base + '/api/tts/speak', {
++ method: 'POST',
++ headers: { 'content-type': 'application/json', ...headers },
++ body: JSON.stringify(body),
++ });
++ return { status: res.status, json: await res.json().catch(() => null) };
++ };
++ try {
++ const none = await say({ text: 'hello' });
++ assert.strictEqual(none.status, 401, 'no token, no voice');
++
++ const one = await say({ text: 'Good evening.', voice: 'gary' }, { 'x-test-person': '1' });
++ assert.strictEqual(one.status, 200, JSON.stringify(one.json));
++ assert.strictEqual(one.json.audio, 'QUFB');
++ assert.strictEqual(one.json.mime_type, 'audio/mpeg');
++ assert.strictEqual(one.json.voice_id, 'gary', 'the voice that was asked for is the voice that answers');
++ assert.deepStrictEqual(calls[calls.length - 1], { text: 'Good evening.', voice: 'gary', who: 'usr_TESTONE' });
++
++ const api = await say({ text: 'hello' }, { 'x-test-person': '1', 'x-test-source': 'api_token' });
++ assert.strictEqual(api.status, 403, 'an API token does not get a voice');
++
++ const long = await say({ text: 'x'.repeat(400) }, { 'x-test-person': '1' });
++ assert.strictEqual(long.status, 200);
++ assert.strictEqual(calls[calls.length - 1].text.length, 220, 'one line: the contract caps the text at 220 characters');
++
++ // The per-person limiter: six lines a minute, the seventh is refused.
++ const who = 'usr_LIMITONE';
++ const codes = [];
++ for (let i = 0; i < 7; i++) {
++ const r = await say({ text: 'line ' + i }, { 'x-test-person': '1', 'x-test-subject': who });
++ codes.push(r.status);
++ }
++ assert.deepStrictEqual(codes.slice(0, 6), [200, 200, 200, 200, 200, 200], 'six lines a minute are spoken');
++ assert.strictEqual(codes[6], 429, 'the seventh line in a minute is refused');
++ const refused = await say({ text: 'again' }, { 'x-test-person': '1', 'x-test-subject': who });
++ assert.strictEqual(refused.json.code, 'voice_rate_limited');
++
++ const other = await say({ text: 'mine' }, { 'x-test-person': '1', 'x-test-subject': 'usr_LIMITTWO' });
++ assert.strictEqual(other.status, 200, 'another person has their own count');
++ } finally {
++ server.close();
++ }
++ console.log('companion voice: the person\'s own token, the voice it is given, one line, six a minute');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });