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
Changes
@@@ -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"@@@ -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": {@@@ -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 */ }nnew file mode 100644@@@ -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; } };nnew file mode 100644@@@ -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); });
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
"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"
"ws": "^8.18.0"
},
},
"engines": {
"engines": {
@@ -971,9 +971,9 @@
@@ -971,9 +971,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
"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"
"ws": "^8.18.0"
},
},
"engines": {
"engines": {
@@ -84,8 +84,13 @@ const soundUpload = multer({
@@ -84,8 +84,13 @@ const soundUpload = multer({
});
});
/** Transcode an audio file to a canonical MP3 (fast decode, small, universal). Returns the
/** 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) => {
return new Promise((resolve) => {
const outPath = srcPath.replace(/\.[^.]+$/, '') + '.conv.mp3';
const outPath = srcPath.replace(/\.[^.]+$/, '') + '.conv.mp3';
let proc;
let proc;
@@ -236,7 +241,7 @@ router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {
@@ -236,7 +241,7 @@ router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {
let finalPath = req.file.path;
let finalPath = req.file.path;
let finalMime = req.file.mimetype;
let finalMime = req.file.mimetype;
if (path.extname(finalPath).toLowerCase() !== '.mp3') {
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) {
if (mp3) {
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
finalPath = mp3;
finalPath = mp3;
@@ -432,7 +437,7 @@ router.post('/alert/:kind', requireAuth, soundUpload.single('sound'), async (req
@@ -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.
// Normalize to mp3 for universal playback; fall back to the original on failure.
let finalPath = req.file.path;
let finalPath = req.file.path;
try {
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; }
if (mp3) { fs.unlink(req.file.path, () => {}); finalPath = mp3; }
} catch { /* keep original */ }
} catch { /* keep original */ }
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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); });