Video calls announce video in the JMI propose (Monal aborts video it wasn't told about); without a camera the call stays audio. Video calls start on the loudspeaker, like Monal. Monal's duplicate H.264 profile_level_id is written once into the SDP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
12 KiB
TypeScript
228 lines
12 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import parse from "ltx/lib/parse.js";
|
|
import { xml } from "@xmpp/client";
|
|
import { candidateToElement, candidateToSdp, iceCredentials, jingleToSdp, mediaOf, sdpToJingle } from "../src/call/jingle.ts";
|
|
|
|
const el = (s: string) => parse(s) as any;
|
|
|
|
// createOffer() of HeadlessChrome 154 with one audio track (2026-09-29).
|
|
const chromeOffer = [
|
|
"v=0",
|
|
"o=- 4672937091243051060 2 IN IP4 127.0.0.1",
|
|
"s=-",
|
|
"t=0 0",
|
|
"a=group:BUNDLE 0",
|
|
"a=extmap-allow-mixed",
|
|
"a=msid-semantic: WMS 03a9ddca-6bcd-4716-96fe-c8c235f860dc",
|
|
"m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126",
|
|
"c=IN IP4 0.0.0.0",
|
|
"a=rtcp:9 IN IP4 0.0.0.0",
|
|
"a=ice-ufrag:qk6w",
|
|
"a=ice-pwd:ZG57QvXY+/NrUiHOgyhdPPGf",
|
|
"a=ice-options:trickle",
|
|
"a=fingerprint:sha-256 AA:3E:52:FB:2D:C7:B9:98:4D:28:B7:A3:36:B4:A8:91:5D:91:83:C6:99:71:2F:06:7A:B1:55:81:B1:C2:2B:48",
|
|
"a=setup:actpass",
|
|
"a=mid:0",
|
|
"a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level",
|
|
"a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time",
|
|
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01",
|
|
"a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid",
|
|
"a=sendrecv",
|
|
"a=msid:03a9ddca-6bcd-4716-96fe-c8c235f860dc afdb8a22-1c3e-4d29-87c4-243624a64a4b",
|
|
"a=rtcp-mux",
|
|
"a=rtcp-rsize",
|
|
"a=rtpmap:111 opus/48000/2",
|
|
"a=rtcp-fb:111 transport-cc",
|
|
"a=fmtp:111 minptime=10;useinbandfec=1",
|
|
"a=rtpmap:63 red/48000/2",
|
|
"a=fmtp:63 111/111",
|
|
"a=rtpmap:9 G722/8000",
|
|
"a=rtpmap:0 PCMU/8000",
|
|
"a=rtpmap:8 PCMA/8000",
|
|
"a=rtpmap:13 CN/8000",
|
|
"a=rtpmap:110 telephone-event/48000",
|
|
"a=rtpmap:126 telephone-event/8000",
|
|
"a=ssrc:743182082 cname:m9CPfpHn7vEwqprQ",
|
|
"a=ssrc:743182082 msid:03a9ddca-6bcd-4716-96fe-c8c235f860dc afdb8a22-1c3e-4d29-87c4-243624a64a4b",
|
|
"",
|
|
].join("\r\n");
|
|
|
|
// A session-initiate as Monal's sdp-to-jingle writes it (shape from its
|
|
// SDPTest.m), audio only, with a relay candidate on a documentation address.
|
|
const monalInitiate = `<jingle xmlns="urn:xmpp:jingle:1" action="session-initiate" sid="0d6e9a52-8c5b-4c3e-9f5e-2a1b3c4d5e6f">
|
|
<group xmlns="urn:xmpp:jingle:apps:grouping:0" semantics="BUNDLE"><content name="0"/></group>
|
|
<content xmlns="urn:xmpp:jingle:1" creator="initiator" senders="both" name="0">
|
|
<transport xmlns="urn:xmpp:jingle:transports:ice-udp:1" pwd="+g6eHJ3YWHoXNdOM60q0LH85" ufrag="LlJY">
|
|
<candidate xmlns="urn:xmpp:jingle:transports:ice-udp:1" id="563352000" component="1" foundation="1076231993" generation="0" ip="198.51.100.52" port="50002" priority="41885694" protocol="udp" rel-addr="0.0.0.0" rel_port="0" type="relay"/>
|
|
<trickle xmlns="http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option"/>
|
|
<renomination xmlns="http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option"/>
|
|
<fingerprint xmlns="urn:xmpp:jingle:apps:dtls:0" hash="sha-256" setup="actpass">3D:7C:7D:EC:CC:84:39:F2:46:A9:10:03:9E:09:FD:4D:E7:9A:49:6D:54:84:F5:5A:10:C1:09:A3:1F:B1:68:D2</fingerprint>
|
|
</transport>
|
|
<description xmlns="urn:xmpp:jingle:apps:rtp:1" media="audio">
|
|
<extmap-allow-mixed/>
|
|
<rtp-hdrext xmlns="urn:xmpp:jingle:apps:rtp:rtp-hdrext:0" id="1" uri="urn:ietf:params:rtp-hdrext:ssrc-audio-level"/>
|
|
<rtp-hdrext xmlns="urn:xmpp:jingle:apps:rtp:rtp-hdrext:0" id="4" uri="urn:ietf:params:rtp-hdrext:sdes:mid"/>
|
|
<rtcp-mux/>
|
|
<payload-type id="111" name="opus" clockrate="48000" channels="2">
|
|
<rtcp-fb xmlns="urn:xmpp:jingle:apps:rtp:rtcp-fb:0" type="transport-cc"/>
|
|
<parameter name="profile_level_id" value="4325392"/>
|
|
<parameter name="useinbandfec" value="true"/>
|
|
<parameter name="minptime" value="10"/>
|
|
</payload-type>
|
|
<payload-type id="63" name="red" clockrate="48000" channels="2">
|
|
<parameter name="profile_level_id" value="4325392"/>
|
|
<parameter name="encodings" value="111"/>
|
|
<parameter name="encodings" value="111"/>
|
|
</payload-type>
|
|
<payload-type id="0" name="PCMU" clockrate="8000"/>
|
|
<payload-type id="126" name="telephone-event" clockrate="8000"/>
|
|
<source xmlns="urn:xmpp:jingle:apps:rtp:ssma:0" ssrc="388312053">
|
|
<parameter name="cname" value="EKc2Iz0i3z5nyiVy"/>
|
|
<parameter name="msid" value="stream audio0"/>
|
|
</source>
|
|
</description>
|
|
</content>
|
|
</jingle>`;
|
|
|
|
const wrap = (children: any[]) => {
|
|
const j = xml("jingle", { xmlns: "urn:xmpp:jingle:1", action: "session-initiate", sid: "s1" });
|
|
j.append(...children);
|
|
// Through a string, as the peer would receive it.
|
|
return el(j.toString());
|
|
};
|
|
|
|
test("Chrome offer as Jingle", () => {
|
|
const [group, content] = sdpToJingle(chromeOffer, true);
|
|
assert.equal(group.attrs.semantics, "BUNDLE");
|
|
assert.deepEqual(group.getChildren("content").map((c: any) => c.attrs.name), ["0"]);
|
|
assert.equal(content.attrs.creator, "initiator");
|
|
assert.equal(content.attrs.name, "0");
|
|
assert.equal(content.attrs.senders, "both");
|
|
const desc = content.getChild("description", "urn:xmpp:jingle:apps:rtp:1");
|
|
assert.equal(desc.attrs.media, "audio");
|
|
const pts = desc.getChildren("payload-type");
|
|
assert.deepEqual(pts.map((p: any) => p.attrs.id), ["111", "63", "9", "0", "8", "13", "110", "126"]);
|
|
const opus = pts[0];
|
|
assert.deepEqual(opus.attrs, { id: "111", name: "opus", clockrate: "48000", channels: "2" });
|
|
assert.deepEqual(
|
|
opus.getChildren("parameter").map((p: any) => `${p.attrs.name}=${p.attrs.value}`),
|
|
["minptime=10", "useinbandfec=1"],
|
|
);
|
|
assert.equal(opus.getChild("rtcp-fb").attrs.type, "transport-cc");
|
|
assert.deepEqual(pts[1].getChildren("parameter").map((p: any) => p.attrs.value), ["111", "111"]);
|
|
assert.equal(pts[2].attrs.channels, undefined);
|
|
assert.equal(desc.getChildren("rtp-hdrext").length, 4);
|
|
assert.ok(desc.getChild("rtcp-mux"));
|
|
const source = desc.getChild("source", "urn:xmpp:jingle:apps:rtp:ssma:0");
|
|
assert.equal(source.attrs.ssrc, "743182082");
|
|
assert.equal(source.getChildren("parameter")[0].attrs.value, "m9CPfpHn7vEwqprQ");
|
|
const transport = content.getChild("transport", "urn:xmpp:jingle:transports:ice-udp:1");
|
|
assert.equal(transport.attrs.ufrag, "qk6w");
|
|
assert.equal(transport.attrs.pwd, "ZG57QvXY+/NrUiHOgyhdPPGf");
|
|
const fp = transport.getChild("fingerprint", "urn:xmpp:jingle:apps:dtls:0");
|
|
assert.equal(fp.attrs.hash, "sha-256");
|
|
assert.equal(fp.attrs.setup, "actpass");
|
|
assert.match(fp.text(), /^AA:3E:52/);
|
|
});
|
|
|
|
test("Chrome offer survives the round trip through Jingle", () => {
|
|
const sdp = jingleToSdp(wrap(sdpToJingle(chromeOffer, true)), "s1", true);
|
|
for (const line of [
|
|
"a=group:BUNDLE 0",
|
|
"m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126",
|
|
"a=ice-ufrag:qk6w",
|
|
"a=ice-pwd:ZG57QvXY+/NrUiHOgyhdPPGf",
|
|
"a=setup:actpass",
|
|
"a=mid:0",
|
|
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01",
|
|
"a=sendrecv",
|
|
"a=rtcp-mux",
|
|
"a=rtpmap:111 opus/48000/2",
|
|
"a=rtcp-fb:111 transport-cc",
|
|
"a=fmtp:111 minptime=10;useinbandfec=1",
|
|
"a=fmtp:63 111/111",
|
|
"a=rtpmap:9 G722/8000",
|
|
"a=ssrc:743182082 cname:m9CPfpHn7vEwqprQ",
|
|
]) {
|
|
assert.ok(sdp.split("\r\n").includes(line), `missing ${line}\n${sdp}`);
|
|
}
|
|
assert.match(sdp, /^v=0\r\n/);
|
|
assert.match(sdp, /a=fingerprint:sha-256 AA:3E:52:FB/);
|
|
});
|
|
|
|
test("Monal's session-initiate as SDP for the browser", () => {
|
|
const sdp = jingleToSdp(el(monalInitiate), "0d6e9a52-8c5b-4c3e-9f5e-2a1b3c4d5e6f", true).split("\r\n");
|
|
assert.ok(sdp.includes("m=audio 9 UDP/TLS/RTP/SAVPF 111 63 0 126"));
|
|
// Booleans back as 1, the H.264 default dropped from audio codecs.
|
|
assert.ok(sdp.includes("a=fmtp:111 useinbandfec=1;minptime=10"));
|
|
assert.ok(sdp.includes("a=fmtp:63 111/111"));
|
|
assert.ok(sdp.includes("a=ice-options:trickle renomination"));
|
|
assert.ok(sdp.includes("a=fingerprint:sha-256 3D:7C:7D:EC:CC:84:39:F2:46:A9:10:03:9E:09:FD:4D:E7:9A:49:6D:54:84:F5:5A:10:C1:09:A3:1F:B1:68:D2"));
|
|
assert.ok(sdp.includes("a=candidate:1076231993 1 udp 41885694 198.51.100.52 50002 typ relay raddr 0.0.0.0 rport 0 generation 0"));
|
|
assert.ok(sdp.includes("a=ssrc:388312053 msid:stream audio0"));
|
|
assert.ok(sdp.includes("a=extmap-allow-mixed"));
|
|
assert.ok(!sdp.some((l) => l.includes("profile")));
|
|
});
|
|
|
|
test("Monal's H.264 parameters as one fmtp line each", () => {
|
|
const video = el(`<jingle xmlns="urn:xmpp:jingle:1" action="session-initiate" sid="x">
|
|
<content xmlns="urn:xmpp:jingle:1" creator="initiator" senders="both" name="1">
|
|
<description xmlns="urn:xmpp:jingle:apps:rtp:1" media="video">
|
|
<payload-type id="102" name="H264" clockrate="90000">
|
|
<parameter name="packetization_mode" value="1"/>
|
|
<parameter name="level_asymmetry_allowed" value="true"/>
|
|
<parameter name="profile_level_id" value="4325407"/>
|
|
<parameter name="profile_level_id" value="4325407"/>
|
|
</payload-type>
|
|
<payload-type id="103" name="rtx" clockrate="90000"><parameter name="apt" value="102"/></payload-type>
|
|
</description>
|
|
<transport xmlns="urn:xmpp:jingle:transports:ice-udp:1" ufrag="u" pwd="p"/>
|
|
</content>
|
|
</jingle>`);
|
|
const sdp = jingleToSdp(video, "x", true).split("\r\n");
|
|
assert.ok(sdp.includes("m=video 9 UDP/TLS/RTP/SAVPF 102 103"));
|
|
assert.ok(sdp.includes("a=fmtp:102 packetization-mode=1;level-asymmetry-allowed=1;profile-level-id=42001f"));
|
|
assert.ok(sdp.includes("a=fmtp:103 apt=102"));
|
|
});
|
|
|
|
test("senders follow who sends, relative to the initiator", () => {
|
|
const answer = chromeOffer.replace("a=sendrecv", "a=recvonly").replace("a=setup:actpass", "a=setup:active");
|
|
const [, content] = sdpToJingle(answer, false);
|
|
// The responder only receives: only the initiator sends.
|
|
assert.equal(content.attrs.senders, "initiator");
|
|
assert.equal(content.getChild("transport").getChild("fingerprint").attrs.setup, "active");
|
|
// Read back by the initiator, it is the responder's recvonly again.
|
|
assert.ok(jingleToSdp(wrap([content]), "s1", false).includes("a=recvonly"));
|
|
assert.ok(jingleToSdp(wrap([content]), "s1", true).includes("a=sendonly"));
|
|
});
|
|
|
|
test("candidates both ways", () => {
|
|
const srflx = "candidate:842163049 1 udp 1677729535 203.0.113.7 46154 typ srflx raddr 192.0.2.10 rport 46154 generation 0 ufrag qk6w network-id 1 network-cost 10";
|
|
const c = candidateToElement(srflx)!;
|
|
assert.equal(c.attrs.type, "srflx");
|
|
assert.equal(c.attrs.ip, "203.0.113.7");
|
|
assert.equal(c.attrs["rel-addr"], "192.0.2.10");
|
|
assert.equal(c.attrs["rel-port"], "46154");
|
|
assert.equal(c.attrs.generation, "0");
|
|
assert.equal(c.attrs.network, "1");
|
|
assert.ok(c.attrs.id);
|
|
assert.equal(candidateToSdp(c), "candidate:842163049 1 udp 1677729535 203.0.113.7 46154 typ srflx raddr 192.0.2.10 rport 46154 generation 0");
|
|
const tcp = candidateToElement("a=candidate:4250347086 1 tcp 1518214911 192.0.2.10 9 typ host tcptype active generation 0 network-id 1")!;
|
|
assert.equal(tcp.attrs["tcp-type"], "active");
|
|
assert.equal(candidateToSdp(tcp), "candidate:4250347086 1 tcp 1518214911 192.0.2.10 9 typ host tcptype active generation 0");
|
|
assert.equal(candidateToElement("candidate:bogus"), null);
|
|
});
|
|
|
|
test("ICE credentials per content and media of a propose", () => {
|
|
assert.deepEqual([...iceCredentials(chromeOffer)], [["0", { ufrag: "qk6w", pwd: "ZG57QvXY+/NrUiHOgyhdPPGf" }]]);
|
|
const propose = el(`<propose xmlns="urn:xmpp:jingle-message:0" id="x"><description xmlns="urn:xmpp:jingle:apps:rtp:1" media="audio"/><description xmlns="urn:xmpp:jingle:apps:rtp:1" media="video"/></propose>`);
|
|
assert.deepEqual(mediaOf(propose), ["audio", "video"]);
|
|
assert.deepEqual(mediaOf(el(monalInitiate)), ["audio"]);
|
|
});
|
|
|
|
test("Opus comes first, whatever order the peer listed", () => {
|
|
const j = el(monalInitiate.replace(/(<payload-type id="111"[\s\S]*?<\/payload-type>)([\s\S]*?)(<payload-type id="0")/, "$2$1$3"));
|
|
const m = jingleToSdp(j, "s", true).split("\r\n").find((l) => l.startsWith("m=audio"));
|
|
assert.equal(m, "m=audio 9 UDP/TLS/RTP/SAVPF 111 63 0 126");
|
|
});
|