/* * verify-windows.js — run this ONCE on a Windows machine after installing. * * Everything in the Windows port that can be checked from a Mac was checked * before shipping: the right binaries are in the package, no Mach-O leaked in, * the sherpa DLLs are present. What could NOT be checked is anything that * requires actually running Windows. This script checks those things, and it * is deliberately blunt about the one number that matters most. * * "C:\Program Files\Church Translate\resources\node.exe" ^ * "C:\Program Files\Church Translate\resources\app\tools\verify-windows.js" * * (Adjust the path if you installed somewhere else. Both paths are printed in * the app's log at data\logs\app.log.) */ 'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFileSync } = require('child_process'); let pass = 0, fail = 0; const ok = (label, good, detail) => { console.log((good ? ' PASS ' : ' FAIL ') + label + (detail ? ' ' + detail : '')); good ? pass++ : fail++; }; console.log('\nChurch Translate — Windows verification\n'); console.log(' platform: ' + process.platform + ' ' + process.arch + ', node ' + process.version); if (process.platform !== 'win32') { console.log('\n This checks the WINDOWS build. On ' + process.platform + ' the expected answers are different (afconvert instead of ffmpeg,\n' + ' MLX instead of ONNX), so it would only report confusing failures. Nothing run.\n'); process.exit(0); } const APP = path.join(__dirname, '..'); const RES = path.join(APP, '..'); // resources/ /* 1. the bundled binaries exist and run */ const ffmpeg = path.join(RES, 'ffmpeg.exe'); ok('ffmpeg.exe is present', fs.existsSync(ffmpeg), ffmpeg); let ffver = ''; try { ffver = String(execFileSync(ffmpeg, ['-hide_banner', '-version'], { encoding: 'utf8', windowsHide: true })).split('\n')[0]; ok('ffmpeg.exe runs', true, ffver); } catch (e) { ok('ffmpeg.exe runs', false, e.message); } /* 2. it encodes ADTS at each rate the app pins, and the app's own reader agrees */ let adts = null; try { adts = require(path.join(APP, 'server', 'live-audio.js')); } catch (e) { console.log(' (could not load live-audio.js: ' + e.message + ')'); } const { encodeAdts, PRIMING_FRAMES } = require(path.join(APP, 'server', 'aac-encode.js')); console.log(' PRIMING_FRAMES on this platform: ' + PRIMING_FRAMES + ' (must be 1 on Windows)'); ok('PRIMING_FRAMES is the Windows value', PRIMING_FRAMES === 1); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ctverify-')); function makeWav(rate, secs, tone) { const n = Math.round(rate * secs), b = Buffer.alloc(44 + n * 2); b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22); b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40); for (let i = 0; i < n; i++) b.writeInt16LE(tone(i, rate), 44 + i * 2); return b; } for (const rate of [16000, 22050, 24000]) { try { const w = path.join(tmp, rate + '.wav'), a = path.join(tmp, rate + '.aac'); fs.writeFileSync(w, makeWav(rate, 2, (i, r) => Math.round(Math.sin(i / r * 2 * Math.PI * 220) * 8000))); encodeAdts(w, a, rate); const buf = fs.readFileSync(a); const frames = adts ? adts.adtsFrames(buf).length : -1; const got = adts ? adts.adtsSampleRate(buf) : rate; ok('encodes ADTS at ' + rate + ' Hz', got === rate && buf.length > 512, frames + ' frames, ' + buf.length + ' bytes'); } catch (e) { ok('encodes ADTS at ' + rate + ' Hz', false, e.message); } } /* * 3. THE ONE NUMBER THAT COULD NOT BE MEASURED FROM macOS. * * The stream drops PRIMING_FRAMES frames off the front of every clip to remove * the encoder's priming. That was measured as 1024 samples on ffmpeg 6.0; the * bundled build is newer. If this reports anything other than ~1024, tell * Claude — every spoken sentence would be losing its first syllable. */ try { const rate = 24000; const w = path.join(tmp, 'click.wav'), a = path.join(tmp, 'click.aac'), d = path.join(tmp, 'click.dec.wav'); /* A signal that is loud from sample 0, so the offset is unambiguous. */ fs.writeFileSync(w, makeWav(rate, 1, (i, r) => Math.round(Math.sin(i / r * 2 * Math.PI * 440) * 12000))); encodeAdts(w, a, rate); execFileSync(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', '-i', a, '-f', 'wav', '-c:a', 'pcm_s16le', '-ar', String(rate), '-ac', '1', d], { windowsHide: true }); const src = fs.readFileSync(w), dec = fs.readFileSync(d); const S = (b) => { const n = (b.length - 44) >> 1; const o = new Int16Array(n); for (let i = 0; i < n; i++) o[i] = b.readInt16LE(44 + i * 2); return o; }; const a0 = S(src), b0 = S(dec); let best = 0, bestScore = -Infinity; for (let lag = 0; lag <= 4096; lag++) { let dot = 0; for (let i = 0; i < 4000; i++) dot += a0[i] * (b0[i + lag] || 0); if (dot > bestScore) { bestScore = dot; best = lag; } } const frames = Math.round(best / 1024); ok('encoder priming is ' + best + ' samples (~' + frames + ' frame' + (frames === 1 ? '' : 's') + ')', frames === PRIMING_FRAMES, frames === PRIMING_FRAMES ? 'matches PRIMING_FRAMES' : 'MISMATCH — speech onsets will be clipped, report this'); } catch (e) { ok('measure encoder priming', false, e.message); } /* 3b. THE NATURAL VOICES. Kokoro runs here through ONNX, not MLX. */ try { const vdir = path.join(APP, 'node_modules', 'kokoro-js', 'voices'); const male = ['em_alex', 'em_santa', 'pm_alex', 'pm_santa'].filter((v) => fs.existsSync(path.join(vdir, v + '.bin'))); ok('Spanish/Portuguese male style vectors present', male.length === 4, male.join(', ')); ok('espeak-ng (phonemiser) present', fs.existsSync(path.join(APP, 'node_modules', 'espeak-ng'))); ok('kokoro-sidecar.js present', fs.existsSync(path.join(APP, 'kokoro-sidecar.js'))); const ort = path.join(APP, 'node_modules', 'onnxruntime-node', 'bin', 'napi-v3'); const plats = fs.existsSync(ort) ? fs.readdirSync(ort) : []; ok('onnxruntime has the win32 binaries', plats.includes('win32'), plats.join(', ')); } catch (e) { ok('natural-voice files present', false, e.message); } /* 4. the native voice engine loads its Windows DLLs */ try { require(path.join(APP, 'node_modules', 'sherpa-onnx-node')); ok('sherpa-onnx native module loads', true); } catch (e) { ok('sherpa-onnx native module loads', false, e.message.slice(0, 120)); } /* 5. the big directories are on the LOCAL profile, not roaming */ const local = process.env.LOCALAPPDATA || ''; ok('LOCALAPPDATA is set', !!local, local); for (const d of ['Church Translate', 'Church Translate Models', 'Church Translate Voices']) { const p = path.join(local, d); console.log(' ' + (fs.existsSync(p) ? 'exists ' : 'not yet ') + p); } const roaming = process.env.APPDATA || ''; if (roaming) { const bad = ['Church Translate Models', 'Church Translate Voices'].filter((d) => fs.existsSync(path.join(roaming, d))); ok('nothing heavy landed in the ROAMING profile', bad.length === 0, bad.join(', ') || roaming); } /* 6. interface labelling — the QR must not land on a virtual adapter */ try { const ps = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); const out = execFileSync(ps, ['-NoProfile', '-NonInteractive', '-Command', 'Get-NetAdapter | Select-Object Name,InterfaceDescription,Virtual | ConvertTo-Json -Compress'], { encoding: 'utf8', timeout: 10000, windowsHide: true }); let rows = JSON.parse(out); if (!Array.isArray(rows)) rows = [rows]; ok('Get-NetAdapter works (QR can tell Wi-Fi from Hyper-V)', rows.length > 0, rows.length + ' adapters'); const nets = os.networkInterfaces(); for (const name of Object.keys(nets)) { for (const ni of nets[name] || []) { if (ni.family !== 'IPv4' || ni.internal || ni.address.startsWith('169.254.')) continue; const r = rows.find((x) => x && x.Name === name); const virt = r ? (!!r.Virtual || /hyper-v|virtual|vmware|wsl|loopback/i.test(String(r.InterfaceDescription || ''))) : false; const wifi = /wi-?fi|wlan|wireless/i.test(name) || (r && /wi-?fi|wlan|wireless/i.test(String(r.InterfaceDescription || ''))); console.log(' ' + ni.address.padEnd(16) + name.padEnd(24) + (wifi ? 'WIFI ' : ' ') + (virt ? 'virtual (will be sorted last)' : '')); } } } catch (e) { ok('Get-NetAdapter works', false, e.message.slice(0, 120)); } try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} /* 7. the natural-voice sidecar actually speaks. The slow one: it downloads ~320 MB of Kokoro on first run, so allow a few minutes on a cold cache. */ if (process.argv.includes('--voices')) { const { spawnSync, spawn } = require('child_process'); const port = 43117; console.log('\n starting the Kokoro sidecar on ' + port + ' (first run downloads ~320 MB)…'); const child = spawn(process.execPath, [path.join(APP, 'kokoro-sidecar.js'), String(port)], { stdio: ['ignore', 'inherit', 'inherit'], windowsHide: true, env: Object.assign({}, process.env, { CT_MODELS_DIR: path.join(process.env.LOCALAPPDATA || '', 'Church Translate Models') }) }); const deadline = Date.now() + 15 * 60 * 1000; const poll = () => new Promise((res) => { require('http').get({ host: '127.0.0.1', port, path: '/healthz', timeout: 2000 }, (r) => { let b = ''; r.on('data', (d) => b += d); r.on('end', () => res(b.indexOf('"ready":true') !== -1)); }).on('error', () => res(false)).on('timeout', function () { this.destroy(); res(false); }); }); (async () => { while (Date.now() < deadline && !(await poll())) await new Promise((r) => setTimeout(r, 3000)); const body = JSON.stringify({ model: 'prince-canuma/Kokoro-82M', voice: 'em_alex', input: 'Bendecimos el nombre de Jesús.', lang_code: 'e', speed: 1.0, response_format: 'wav' }); const got = await new Promise((res) => { const req = require('http').request({ host: '127.0.0.1', port, path: '/v1/audio/speech', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 120000 }, (r) => { const c = []; r.on('data', (d) => c.push(d)); r.on('end', () => res({ status: r.statusCode, buf: Buffer.concat(c) })); }); req.on('error', (e) => res({ status: 0, buf: Buffer.alloc(0), err: e.message })); req.end(body); }); const n = got.buf.length > 44 ? (got.buf.length - 44) / 2 / 24000 : 0; ok('Kokoro speaks Spanish (em_alex)', got.status === 200 && n > 0.5, 'HTTP ' + got.status + ', ' + n.toFixed(2) + 's of audio' + (got.err ? ' — ' + got.err : '')); try { child.kill(); } catch (_) {} console.log('\n' + (fail ? fail + ' FAILED — send this output to Claude' : 'all ' + pass + ' checks passed') + '\n'); process.exit(fail ? 1 : 0); })(); } else { console.log('\n (add --voices to also test the natural voices — downloads ~320 MB the first time)'); console.log('\n' + (fail ? fail + ' FAILED — send this output to Claude' : 'all ' + pass + ' checks passed') + '\n'); process.exit(fail ? 1 : 0); }