openvibers/openvibe.chat Commits
Companions S6: the voice route a person calls with their own token (#370)
Changes
@@@ -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'));nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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); });
@@ -318,6 +318,9 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -318,6 +318,9 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
app.use('/api/dm', require('./chat/dm-routes'));
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.
// 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'));
+ // 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'));
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).
// 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/agent-voice', require('./chat/tts-machine'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -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); });