openvibers/openvibe.chat Commits

Sound uploads convert through OpenVibe.Tools (platform S9): the mp3 tool's run API via openvibe-sdk/tools (v0.6.0) with Chat's service token (tools.tool.run, tools.job.read), 128 kbps 44.1 kHz stereo, the result downloaded from the jobs facade; local ffmpeg whenever Tools is down, busy or refuses (CHAT_TOOLS_AUDIO=off skips it). test/tools-audio.test.js

OP OpenVibers committed 24 Sept 2026 · 1d0e12afb9 · 5 files, +133 −9


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 6904c3a..3356d0f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
"ws": "^8.18.0"
},
"engines": {
@@@ -971,9 +971,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.5.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
-- "integrity": "sha512-QVJkR5iazQX0ZwIrDp2A88ZeYYBSXEmSzhpgURPUSnzezIM3jz0HBhVKowyhkvg2aBO61XNf77eb4zJGkMjZiw==",
++ "version": "0.6.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
++ "integrity": "sha512-v6PFh8Xf/bE1XDbuSkbVXVC5cN+q9fkwQi/qc9kmiGPr9Zze47ydS+qS0i3RXUmQF2v0/BdCzAMcowsdzf1ezQ==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 6dffd41..ceff79e 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
"ws": "^8.18.0"
},
"engines": {
ddiff --git a/server/chat/sounds-routes.js b/server/chat/sounds-routes.js
iindex c2f0992..1a88170 100644
---- a/server/chat/sounds-routes.js
++++ b/server/chat/sounds-routes.js
@@@ -84,8 +84,13 @@ const soundUpload = multer({
});
/** Transcode an audio file to a canonical MP3 (fast decode, small, universal). Returns the
-- * new path on success (original left for the caller to delete), or null on failure. */
--function convertToMp3(srcPath) {
++ * new path on success (original left for the caller to delete), or null on failure.
++ * OpenVibe.Tools' mp3 tool does the work when it can; local ffmpeg otherwise. */
++async function convertToMp3(srcPath, mime) {
++ const viaTools = await require('./tools-audio').convertViaTools(srcPath, { mime });
++ return viaTools || convertLocally(srcPath);
++}
++function convertLocally(srcPath) {
return new Promise((resolve) => {
const outPath = srcPath.replace(/\.[^.]+$/, '') + '.conv.mp3';
let proc;
@@@ -236,7 +241,7 @@ router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {
let finalPath = req.file.path;
let finalMime = req.file.mimetype;
if (path.extname(finalPath).toLowerCase() !== '.mp3') {
-- const mp3 = await convertToMp3(req.file.path);
++ const mp3 = await convertToMp3(req.file.path, req.file.mimetype);
if (mp3) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
finalPath = mp3;
@@@ -432,7 +437,7 @@ router.post('/alert/:kind', requireAuth, soundUpload.single('sound'), async (req
// Normalize to mp3 for universal playback; fall back to the original on failure.
let finalPath = req.file.path;
try {
-- const mp3 = await convertToMp3(req.file.path);
++ const mp3 = await convertToMp3(req.file.path, req.file.mimetype);
if (mp3) { fs.unlink(req.file.path, () => {}); finalPath = mp3; }
} catch { /* keep original */ }
ddiff --git a/server/chat/tools-audio.js b/server/chat/tools-audio.js
nnew file mode 100644
iindex 0000000..a823eba
---- /dev/null
++++ b/server/chat/tools-audio.js
@@@ -0,0 +1,57 @@
++'use strict';
++/**
++ * Sound uploads → MP3 through OpenVibe.Tools (platform S9): the `mp3` tool's run API (an audio.process
++ * job on the audio satellite) with Chat's service token (tools.tool.run + tools.job.read on
++ * openvibe.tools), through openvibe-sdk/tools. The caller keeps its local ffmpeg as the fallback:
++ * null here means "convert it yourself" (Tools down, busy, refused, no secret, or CHAT_TOOLS_AUDIO=off).
++ */
++const fs = require('fs');
++const path = require('path');
++const { createClient } = require('openvibe-sdk/core');
++const { createToolsClient } = require('openvibe-sdk/tools');
++const serviceAuth = require('../net/service-auth');
++const config = require('../config');
++
++const AUDIENCE = 'openvibe.tools';
++const TIMEOUT_MS = 45_000;
++const MAX_BYTES = 25 * 1024 * 1024;
++let tools = null;
++const stats = { ok: 0, fallback: 0, last_error: null };
++
++function client() {
++ if (tools) return tools;
++ const tokenProvider = {
++ async getToken() { const h = await serviceAuth.headers(AUDIENCE); return String(h.Authorization || h.authorization || '').replace(/^Bearer\s+/i, ''); },
++ invalidate() { serviceAuth.invalidate(AUDIENCE); },
++ };
++ const base = String(process.env.OV_TOOLS_INTERNAL_URL || 'http://127.0.0.1:4001').replace(/\/+$/, '');
++ tools = createToolsClient(createClient({ baseUrls: { tools: base }, tokenProvider, retries: 0 }));
++ return tools;
++}
++
++/** Transcode to 128 kbps 44.1 kHz stereo MP3 via Tools; the new file's path, or null (use ffmpeg). */
++async function convertViaTools(srcPath, { mime = 'application/octet-stream', timeoutMs = TIMEOUT_MS } = {}) {
++ if (process.env.CHAT_TOOLS_AUDIO === 'off' || !config.oauth.clientSecret) return null;
++ const signal = AbortSignal.timeout(timeoutMs);
++ try {
++ const data = await fs.promises.readFile(srcPath);
++ if (!data.length || data.length > MAX_BYTES) return null;
++ const t = client();
++ let run = await t.run('mp3', { bitrate: 128, sampleRate: 44100, channels: 2 }, { files: [{ name: path.basename(srcPath), data, type: mime }], waitMs: 30_000, signal });
++ if (run.state !== 'succeeded') run = await run.wait({ signal });
++ if (!run.job) throw new Error('no job to download from');
++ const res = await t.jobs.file(run.job.id, 0, { signal });
++ const out = Buffer.from(await res.arrayBuffer());
++ if (out.length < 128 || out.length > MAX_BYTES) throw new Error(`result of ${out.length} bytes`);
++ const outPath = srcPath.replace(/\.[^.]+$/, '') + '.conv.mp3';
++ await fs.promises.writeFile(outPath, out);
++ stats.ok++;
++ return outPath;
++ } catch (err) {
++ stats.fallback++;
++ stats.last_error = String((err && (err.code || err.message)) || err).slice(0, 200);
++ return null;
++ }
++}
++
++module.exports = { convertViaTools, stats: () => ({ ...stats }), _reset() { tools = null; stats.ok = 0; stats.fallback = 0; stats.last_error = null; } };
ddiff --git a/test/tools-audio.test.js b/test/tools-audio.test.js
nnew file mode 100644
iindex 0000000..1a465da
---- /dev/null
++++ b/test/tools-audio.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++/** Sound uploads convert through OpenVibe.Tools' mp3 tool with Chat's service token; any failure means null (local ffmpeg). */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++(async () => {
++ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++ const MP3 = Buffer.concat([Buffer.from('ID3'), Buffer.alloc(400, 7)]);
++ let mode = 'ok';
++ const seen = [];
++ const srv = await new Promise((r) => { const s = http.createServer((req, res) => {
++ const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => {
++ const body = Buffer.concat(chunks);
++ seen.push({ method: req.method, url: req.url, auth: req.headers.authorization || null, type: req.headers['content-type'] || '', body });
++ const json = (st, o) => { res.writeHead(st, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
++ if (req.url === '/oauth/token') return json(200, { access_token: 'svc-token', token_type: 'Bearer', expires_in: 300, scope: 'tools.tool.run tools.job.read' });
++ if (req.url.startsWith('/api/v1/tools/mp3/run')) {
++ if (mode === 'busy') return json(503, { type: 'about:blank', title: 'busy', status: 503, code: 'tools.busy' });
++ const job = { object: 'tools.job', id: JOB, state: 'succeeded', result: { files: [{ name: 'a.mp3', mime: 'audio/mpeg', size: MP3.length }], data: {} }, links: { self: `/api/v1/jobs/${JOB}` } };
++ return json(200, { state: 'succeeded', tool: 'mp3', result: job.result, took_ms: 5, job });
++ }
++ if (req.url.startsWith(`/api/v1/jobs/${JOB}/files/0`)) { res.writeHead(200, { 'content-type': 'audio/mpeg' }); return res.end(MP3); }
++ json(404, { type: 'about:blank', title: 'nope', status: 404 });
++ });
++ }).listen(0, '127.0.0.1', () => r(s)); });
++ const base = `http://127.0.0.1:${srv.address().port}`;
++ process.env.OV_TOOLS_INTERNAL_URL = base;
++ process.env.OV_NETWORK_INTERNAL_URL = base;
++ process.env.OV_OAUTH_CLIENT_SECRET = 'chat-secret-for-tests-only-0123456789';
++ const audio = require('../server/chat/tools-audio');
++
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'chat-tools-audio-'));
++ const src = path.join(dir, 'boom.wav');
++ fs.writeFileSync(src, Buffer.from('RIFF....WAVEfmt fake'));
++
++ const out = await audio.convertViaTools(src, { mime: 'audio/wav' });
++ assert.strictEqual(out, path.join(dir, 'boom.conv.mp3'));
++ assert.ok(fs.readFileSync(out).equals(MP3), 'the job\'s file is what gets stored');
++ const run = seen.find((s) => s.url.startsWith('/api/v1/tools/mp3/run'));
++ assert.strictEqual(run.auth, 'Bearer svc-token', 'Chat\'s service token, not anonymous');
++ assert.ok(/multipart\/form-data/.test(run.type), 'the upload travels as multipart');
++ assert.ok(run.body.includes('"bitrate":128') && run.body.includes('"sampleRate":44100') && run.body.includes('RIFF'), 'input and bytes');
++ assert.strictEqual(seen.find((s) => s.url.includes('/files/0')).auth, 'Bearer svc-token');
++
++ mode = 'busy';
++ assert.strictEqual(await audio.convertViaTools(src, { mime: 'audio/wav' }), null, 'Tools busy: the caller converts locally');
++ process.env.CHAT_TOOLS_AUDIO = 'off';
++ mode = 'ok';
++ const before = seen.length;
++ assert.strictEqual(await audio.convertViaTools(src), null, 'switched off');
++ assert.strictEqual(seen.length, before, 'nothing sent while off');
++ delete process.env.CHAT_TOOLS_AUDIO;
++ srv.close();
++ assert.strictEqual(await audio.convertViaTools(src), null, 'Tools down');
++ assert.deepStrictEqual([audio.stats().ok, audio.stats().fallback], [1, 2]);
++ fs.rmSync(dir, { recursive: true, force: true });
++ console.log('tools audio: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });