The missing half of #189. A call placed as a video call already opened a camera; a call placed as a voice call could never grow one, and the button was hidden unless the call was on a media server - so on the two shapes of Matrix call that do not use one, there was no camera at all. Added and removed rather than enabled and disabled. A disabled track keeps sending - a black rectangle at whatever frame rate the encoder feels like - so the far end sees a dark square where a face was and cannot tell that from a camera that has frozen. Removing the track ends it properly, and every other client then draws the person's name. The loopback harness below measures exactly this difference: with the old enabled=false the far end still counts one live video track after the camera is switched off, and with the track removed it counts none. One gesture for all three shapes of call - a media server, a mesh between several people, a single connection between two - because the button is one button and the person pressing it is asking one thing. Which shape this is belongs to the call, not to the control. On a mesh the camera is still opened once and lent to every leg, since five connections asking the machine for the same camera get one capture and four refusals. Renegotiations are now serialised. Turning a camera on and sharing a screen a second later are two changes to one connection, and starting the second offer while the first is unanswered leaves a peer connection neither end can recover from. A remote track that is taken away is dropped from the stream it arrived on, so a tile stops drawing the last frame it received - Chromium reports that as a mute before it reports it as an end, and on a removed track the mute is often all there is. cameraOn is asked of the call rather than assumed, in both branches: that block runs on every phase change, and a fixed answer put the button back to "off" the moment a call connected. The call UI is unchanged in shape and deliberately not Matrix-shaped - Discord's camera (#180) and screen share (#14) get the same row of buttons when they arrive. Co-Authored-By: Claude Opus 5 <[email protected]>
130 lines
4.3 KiB
TypeScript
130 lines
4.3 KiB
TypeScript
// Two ends of a real call in one page, wired to each other.
|
|
//
|
|
// The claim under test is the one that cannot be checked by reading: that a
|
|
// camera turned on in a call already running reaches the other end, and that
|
|
// turning it off again takes it away rather than freezing it. That is
|
|
// offer/answer, a renegotiation each way and a track appearing on a stream
|
|
// somebody else owns - four things no unit test touches.
|
|
//
|
|
// Real RTCPeerConnections over loopback, and a real captured video track from
|
|
// a canvas rather than a webcam, so this runs anywhere.
|
|
import { Call } from '../../src/renderer/src/lib/webrtc'
|
|
|
|
const out: string[] = []
|
|
const say = (line: string): void => {
|
|
out.push(line)
|
|
const el = document.getElementById('log')!
|
|
el.textContent = out.join('\n')
|
|
}
|
|
|
|
/** A moving picture with no camera in it. */
|
|
function fakeCamera(): MediaStream {
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = 320
|
|
canvas.height = 180
|
|
const ctx = canvas.getContext('2d')!
|
|
let frame = 0
|
|
setInterval(() => {
|
|
frame++
|
|
ctx.fillStyle = `hsl(${frame % 360}, 70%, 50%)`
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
}, 40)
|
|
return (canvas as HTMLCanvasElement & { captureStream(fps: number): MediaStream }).captureStream(15)
|
|
}
|
|
|
|
/** A microphone with no microphone in it. */
|
|
function fakeMic(): MediaStream {
|
|
const ctx = new AudioContext()
|
|
const dest = ctx.createMediaStreamDestination()
|
|
const osc = ctx.createOscillator()
|
|
osc.connect(dest)
|
|
osc.start()
|
|
return dest.stream
|
|
}
|
|
|
|
const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
|
|
async function run(): Promise<void> {
|
|
const ice: RTCIceServer[] = []
|
|
let a!: Call
|
|
let b!: Call
|
|
let bRemote: MediaStream | null = null
|
|
let aRemote: MediaStream | null = null
|
|
|
|
a = new Call({
|
|
id: 'test',
|
|
outgoing: true,
|
|
iceServers: ice,
|
|
local: fakeMic(),
|
|
handlers: {
|
|
send: (s) => void deliver('a', s),
|
|
onRemoteStream: (st) => (aRemote = st),
|
|
onPhase: (p) => say(` a: ${p}`),
|
|
onError: (m) => say(` a error: ${m}`)
|
|
}
|
|
})
|
|
b = new Call({
|
|
id: 'test',
|
|
outgoing: false,
|
|
iceServers: ice,
|
|
local: fakeMic(),
|
|
handlers: {
|
|
send: (s) => void deliver('b', s),
|
|
onRemoteStream: (st) => (bRemote = st),
|
|
onPhase: (p) => say(` b: ${p}`),
|
|
onError: (m) => say(` b error: ${m}`)
|
|
}
|
|
})
|
|
|
|
async function deliver(from: string, signal: { kind: string; content: Record<string, unknown> }): Promise<void> {
|
|
const to = from === 'a' ? b : a
|
|
const c = signal.content as Record<string, any>
|
|
if (signal.kind === 'invite') await to.accept(c.offer.sdp, false)
|
|
else if (signal.kind === 'answer') await to.takeAnswer(c.answer.sdp)
|
|
else if (signal.kind === 'candidates') await to.addCandidates(c.candidates)
|
|
else if (signal.kind === 'negotiate') await to.takeNegotiation(c.description)
|
|
}
|
|
|
|
const videoTracks = (s: MediaStream | null): number =>
|
|
(s?.getVideoTracks() ?? []).filter((t) => t.readyState === 'live' && !t.muted).length
|
|
|
|
say('placing a voice call')
|
|
await a.invite(false)
|
|
await wait(1500)
|
|
say(`connected: a sees ${videoTracks(aRemote)} live video, b sees ${videoTracks(bRemote)}`)
|
|
const startedVoiceOnly = videoTracks(aRemote) === 0 && videoTracks(bRemote) === 0
|
|
|
|
say('a turns its camera on mid-call')
|
|
const on = await a.setCamera(true, fakeCamera())
|
|
await wait(2000)
|
|
const bSeesCamera = videoTracks(bRemote)
|
|
say(` a.cameraOn=${on}; b now sees ${bSeesCamera} live video track(s)`)
|
|
|
|
say('a turns it off again')
|
|
const off = await a.setCamera(false)
|
|
await wait(2000)
|
|
const bSeesAfter = videoTracks(bRemote)
|
|
say(` a.cameraOn=${a.cameraOn} (returned ${off}); b now sees ${bSeesAfter}`)
|
|
|
|
say('b turns its camera on, the other direction')
|
|
await b.setCamera(true, fakeCamera())
|
|
await wait(2000)
|
|
const aSeesCamera = videoTracks(aRemote)
|
|
say(` a now sees ${aSeesCamera} live video track(s)`)
|
|
|
|
const verdict =
|
|
startedVoiceOnly && bSeesCamera === 1 && bSeesAfter === 0 && aSeesCamera === 1 ? 'PASS' : 'FAIL'
|
|
say(`\nVERDICT ${verdict}`)
|
|
document.title = `callcheck ${verdict}`
|
|
;(window as unknown as { verdict: string }).verdict = verdict
|
|
|
|
await a.hangUp(undefined, false)
|
|
await b.hangUp(undefined, false)
|
|
}
|
|
|
|
void run().catch((e) => {
|
|
say(`threw: ${(e as Error).message}`)
|
|
document.title = 'callcheck FAIL'
|
|
;(window as unknown as { verdict: string }).verdict = 'FAIL'
|
|
})
|