import { execSync, spawn } from 'child_process'; import { launch, newUser, joinCall, lotusMsgs, sfuParticipants, leaveCall, text, SP, ecFrame } from './lib.mjs'; const browser = await launch(); const A = await newUser(browser, 'alice'); const B = await newUser(browser, 'bob'); // alice: ML denoise on await A.page.locator('button', { hasText: /^a$/ }).first().click(); await A.page.waitForTimeout(800); const calls = A.page.getByText('Calls', { exact: true }).first(); if (await calls.count()) await calls.click(); await A.page.waitForTimeout(400); const nsSel = () => A.page.getByText('Noise Suppression', { exact: true }).locator('xpath=../..').getByRole('button').first(); await nsSel().click(); await A.page.waitForTimeout(400); await A.page.getByText(process.argv[2] === 'ml' ? 'ML (Advanced)' : 'Browser-native', { exact: true }).last().click(); await A.page.waitForTimeout(500); await A.page.keyboard.press('Escape'); await joinCall(A.page); await joinCall(B.page); const aliceOnBob = async () => (await lotusMsgs(B.page)).filter((m) => m.action === 'io.lotus.call_state').pop()?.data?.participants?.map((p) => `${p.userId.slice(1,4)} spk=${p.speaking} aud=${p.audioEnabled}`).join(', '); const aliceOnAlice = async () => (await lotusMsgs(A.page)).filter((m) => m.action === 'io.lotus.call_state').pop()?.data?.participants?.map((p) => `${p.userId.slice(1,4)} spk=${p.speaking} aud=${p.audioEnabled}`).join(', '); const rms = () => ecFrame(B.page).evaluate(async () => { const el = document.querySelector('audio[data-lk-source="microphone"]'); const t = el?.srcObject?.getAudioTracks()[0]; if (!t) return null; const ctx = new AudioContext(); const s = ctx.createMediaStreamSource(new MediaStream([t])); const an = ctx.createAnalyser(); s.connect(an); const buf = new Float32Array(an.fftSize); let max = 0; for (let i = 0; i < 10; i += 1) { await new Promise((r) => setTimeout(r, 100)); an.getFloatTimeDomainData(buf); max = Math.max(max, Math.sqrt(buf.reduce((a, v) => a + v * v, 0) / buf.length)); } await ctx.close(); return +max.toFixed(3); }); await A.page.waitForTimeout(3000); console.log('before: alice per bob =', JSON.stringify(await aliceOnBob()), '| rms at bob:', await rms(), '| EC params ML:', new URL(ecFrame(A.page).url()).searchParams.get('lotusDenoiseSource')); // kill + restart the SFU const pid = execSync("ps -eo pid,args | grep 'livekit-server --config' | grep -v grep | awk '{print $1}'").toString().trim(); console.log('killing livekit pid', pid); execSync(`kill ${pid}`); await A.page.waitForTimeout(2000); (await import('fs')).writeFileSync(`${SP}/lk-restart.flag`, '1'); console.log('asked the shell to restart livekit'); const t0 = Date.now(); let ok = null; while (Date.now() - t0 < 60000 && !ok) { await A.page.waitForTimeout(2000); const ps = await sfuParticipants().catch(() => []); if (ps.length === 2 && ps.every((p) => p.tracks.some((t) => t.startsWith('AUDIO/MICROPHONE')))) ok = ((Date.now() - t0) / 1000).toFixed(0); } console.log('both republished on the new SFU after', ok, 's:', JSON.stringify(await sfuParticipants().catch(() => 'n/a'))); for (const w of [4000, 8000, 12000]) { await A.page.waitForTimeout(w); console.log(` +${w}ms bob sees:`, await aliceOnBob(), '| alice sees:', await aliceOnAlice(), '| status bar A:', (await text(A.page)).match(/[^\n]*speaking[^\n]*/)?.[0]); } const stuck = (await aliceOnBob())?.match(/(\w+) spk=false/)?.[1]; if (stuck) { const P = stuck === 'bob' ? B.page : A.page; await P.getByRole('button', { name: 'Turn Off Microphone' }).first().click(); await P.waitForTimeout(1500); await P.getByRole('button', { name: 'Turn On Microphone' }).first().click(); await P.waitForTimeout(3000); console.log(` ${stuck} toggled mic off/on → bob sees:`, await aliceOnBob()); } const ecTiles = (page) => ecFrame(page).evaluate(() => Array.from(document.querySelectorAll('[data-lk-speaking], [class*="speaking"]')).map((e) => `${e.tagName}.${(e.className || '').toString().slice(0, 40)} lk=${e.getAttribute('data-lk-speaking')}`).slice(0, 4)); console.log(' EC-side speaking markers (bob page):', await ecTiles(B.page)); console.log('after reconnect: bob sees =', JSON.stringify(await aliceOnBob()), '| rms at bob:', await rms(), '| alice UI:', (await text(A.page)).match(/\d+ in call|Reconnect[^\n]*|Connection lost[^\n]*/g)); await A.page.screenshot({ path: `${SP}/audit9-reconnect.png` }); // reset await leaveCall(B.page); await leaveCall(A.page); await A.page.locator('button', { hasText: /^a$/ }).first().click(); await A.page.waitForTimeout(800); if (await calls.count()) await calls.click(); await A.page.waitForTimeout(400); await nsSel().click(); await A.page.waitForTimeout(400); await A.page.getByText('Browser-native', { exact: true }).last().click(); await A.page.waitForTimeout(400); await browser.close();