mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
Mirrors the existing nestsClient/tests/hang-interop/ layout — all T16 cross-stack interop test infrastructure (Rust sidecars + bun browser harness) now lives under nestsClient/tests/. Updates: - nestsClient/build.gradle.kts: browserInteropDir path. - PlaywrightDriver.kt + BrowserInteropTest.kt: kdoc comments. - All plan docs in nestsClient/plans/ that referenced the old path. Compiles clean post-move. No CI changes (those jobs are intentionally not wired per 2026-05-07-cross-stack-interop-ci-gating.md; when re-added they'll reference the new path).
293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
// Phase 4 (T16) browser-publisher harness.
|
|
//
|
|
// Inverse of `listen.ts`: drives a sine `OscillatorNode` through the
|
|
// WebCodecs `AudioEncoder` (Opus mode) and pushes each encoded packet
|
|
// onto a moq-lite track via `Container.Legacy.Producer`, prefixed with
|
|
// a Varint-encoded timestamp the watcher (`Container.Legacy.Format`)
|
|
// will strip on decode. Also publishes a `catalog.json` track that
|
|
// matches `MoqLiteHangCatalog.opus48k` byte-for-byte so the Amethyst
|
|
// listener (and `hang-listen` for cross-validation) can discover the
|
|
// audio rendition.
|
|
//
|
|
// Optional `?reconnectAfterMs=N` URL param: cycles the moq session
|
|
// at N ms into the broadcast — drops the current `Connection`,
|
|
// builds a fresh one, re-publishes the same broadcast suffix. The
|
|
// relay sees `Announce::Ended → Active` on the same path. Used by
|
|
// the Browser I7 scenario (Chromium publisher reconnect → Kotlin
|
|
// listener recovers via `connectReconnectingNestsListener`'s
|
|
// re-issuance pump).
|
|
|
|
import * as Moq from "@moq/lite";
|
|
import * as Container from "@moq/hang/container";
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
const relayParam = params.get("relay");
|
|
const broadcastParam = params.get("broadcast");
|
|
const trackParam = params.get("track") ?? "audio/data";
|
|
const catalogTrack = params.get("catalogTrack") ?? "catalog.json";
|
|
const freqHz = Number(params.get("freqHz") ?? "440");
|
|
const channels = Number(params.get("channels") ?? "1");
|
|
const durationSec = Number(params.get("duration") ?? "5");
|
|
const wsPort = Number(params.get("wsPort") ?? "0");
|
|
const reconnectAfterMs = Number(params.get("reconnectAfterMs") ?? "0");
|
|
const certSha256B64 = params.get("certSha256");
|
|
|
|
function required(v: string | null, name: string): string {
|
|
if (!v) throw new Error(`publish.html: missing ?${name}=`);
|
|
return v;
|
|
}
|
|
const relayUrlString = required(relayParam, "relay");
|
|
const broadcastName = required(broadcastParam, "broadcast");
|
|
|
|
const status = (msg: string) => {
|
|
const el = document.getElementById("status");
|
|
if (el) el.textContent = msg;
|
|
console.log("[publish]", msg);
|
|
};
|
|
|
|
const catalogJson = JSON.stringify({
|
|
audio: {
|
|
renditions: {
|
|
[trackParam]: {
|
|
codec: "opus",
|
|
container: { kind: "legacy" },
|
|
sampleRate: 48000,
|
|
numberOfChannels: channels,
|
|
jitter: 20,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const catalogBytes = new TextEncoder().encode(catalogJson);
|
|
|
|
/**
|
|
* Open one moq-lite session + broadcast. Returns the bits the encoder
|
|
* pump needs (Connection + audio Track) plus a `close` to tear it down
|
|
* cleanly when the reconnect cycle fires.
|
|
*/
|
|
type Session = {
|
|
audioMoqTrack: Moq.Track;
|
|
closeAll: () => void;
|
|
};
|
|
|
|
async function openSession(): Promise<Session> {
|
|
const relayUrl = new URL(relayUrlString);
|
|
status(`connecting to ${relayUrl.toString()}`);
|
|
// serverCertificateHashes pinning per the same comment in listen.ts
|
|
// — Chromium's --ignore-certificate-errors does NOT bypass QUIC
|
|
// cert validation. The test driver passes the SHA-256 of the
|
|
// relay's leaf DER cert via ?certSha256=base64.
|
|
const webtransportOpts: WebTransportOptions = {};
|
|
if (certSha256B64) {
|
|
const raw = Uint8Array.from(atob(certSha256B64), (c) => c.charCodeAt(0));
|
|
webtransportOpts.serverCertificateHashes = [
|
|
{ algorithm: "sha-256", value: raw },
|
|
];
|
|
}
|
|
const conn = await Moq.Connection.connect(relayUrl, {
|
|
websocket: { enabled: false },
|
|
webtransport: webtransportOpts,
|
|
});
|
|
(window as any).__moqVersion = conn.version;
|
|
status(`connected, alpn=${conn.version}`);
|
|
|
|
const broadcast = new Moq.Broadcast();
|
|
conn.publish(Moq.Path.from(broadcastName), broadcast);
|
|
status(`announced ${broadcastName}`);
|
|
|
|
let audioTrackResolved: Moq.Track | undefined;
|
|
const audioTrackResolver = new Promise<Moq.Track>((resolve) => {
|
|
const probe = setInterval(() => {
|
|
if (audioTrackResolved) {
|
|
clearInterval(probe);
|
|
resolve(audioTrackResolved);
|
|
}
|
|
}, 20);
|
|
});
|
|
|
|
// Serve catalog + audio tracks as they're requested by the relay.
|
|
const requestPump = (async () => {
|
|
for (;;) {
|
|
const req = await broadcast.requested();
|
|
if (!req) return;
|
|
if (req.track.name === catalogTrack) {
|
|
const group = req.track.appendGroup();
|
|
group.writeFrame(catalogBytes);
|
|
group.close();
|
|
} else if (req.track.name === trackParam) {
|
|
audioTrackResolved = req.track;
|
|
}
|
|
}
|
|
})().catch((e) => console.error("[publish] requests:", e));
|
|
|
|
const audioMoqTrack = await audioTrackResolver;
|
|
|
|
const closeAll = () => {
|
|
try {
|
|
broadcast.close();
|
|
} catch (_) {
|
|
// ignore
|
|
}
|
|
try {
|
|
conn.close();
|
|
} catch (_) {
|
|
// ignore
|
|
}
|
|
// requestPump exits on its own once broadcast.requested()
|
|
// returns null after broadcast.close().
|
|
void requestPump;
|
|
};
|
|
|
|
return { audioMoqTrack, closeAll };
|
|
}
|
|
|
|
async function main() {
|
|
let ws: WebSocket | undefined;
|
|
if (wsPort) {
|
|
ws = new WebSocket(`ws://127.0.0.1:${wsPort}/pcm`);
|
|
await new Promise<void>((resolve) => {
|
|
ws!.addEventListener("open", () => resolve(), { once: true });
|
|
ws!.addEventListener("error", () => resolve(), { once: true });
|
|
});
|
|
}
|
|
const sendDone = () => {
|
|
if (ws?.readyState === WebSocket.OPEN) ws.send("done");
|
|
};
|
|
|
|
// Open the FIRST session.
|
|
let session = await openSession();
|
|
|
|
// -- Audio source pump (single source across reconnect cycles) -----
|
|
// Sine osc → MediaStreamAudioDestinationNode → MediaStreamTrack →
|
|
// MediaStreamTrackProcessor → AudioData. The osc + processor
|
|
// SURVIVE a reconnect — only the moq-lite Producer (which writes
|
|
// to the per-cycle session's track) is rebuilt.
|
|
const ctx = new AudioContext({ sampleRate: 48_000, latencyHint: "interactive" });
|
|
await ctx.resume();
|
|
const osc = ctx.createOscillator();
|
|
osc.frequency.value = freqHz;
|
|
osc.type = "sine";
|
|
const dst = ctx.createMediaStreamDestination();
|
|
// The destination's channelCount defaults to 2 (stereo); pin it
|
|
// to whatever the test configured so the AudioEncoder's
|
|
// `numberOfChannels` matches what AudioData carries. Mismatch
|
|
// surfaces as `EncodingError: Input audio buffer is incompatible
|
|
// with codec parameters` and immediately closes the codec.
|
|
dst.channelCount = channels;
|
|
dst.channelCountMode = "explicit";
|
|
dst.channelInterpretation = "speakers";
|
|
osc.connect(dst);
|
|
osc.start();
|
|
|
|
const audioTrack = dst.stream.getAudioTracks()[0];
|
|
// @ts-expect-error MediaStreamTrackProcessor is Chrome-only
|
|
const processor = new MediaStreamTrackProcessor({ track: audioTrack });
|
|
const reader = (processor.readable as ReadableStream<AudioData>).getReader();
|
|
|
|
// Producer is rebuilt on each reconnect cycle.
|
|
let producer = new Container.Legacy.Producer(session.audioMoqTrack);
|
|
let producerStarted = false;
|
|
let cycleId = 0;
|
|
|
|
const encoder = new AudioEncoder({
|
|
output: (chunk, _meta) => {
|
|
const data = new Uint8Array(chunk.byteLength);
|
|
chunk.copyTo(data);
|
|
// Force a new group at each cycle's start so the first
|
|
// post-reconnect frame is a keyframe — Container.Legacy
|
|
// requires it. `producerStarted` tracks per-producer.
|
|
const isKey = !producerStarted;
|
|
producerStarted = true;
|
|
try {
|
|
producer.encode(data, chunk.timestamp as any, isKey);
|
|
} catch (e) {
|
|
// The producer can throw if the underlying session
|
|
// closed mid-encode (we're between cycles). Swallow
|
|
// — the next encoded chunk lands on the new producer.
|
|
console.warn("[publish] encoder.output: producer.encode threw", e);
|
|
}
|
|
},
|
|
error: (e) => console.error("[publish] AudioEncoder", e),
|
|
});
|
|
encoder.configure({
|
|
codec: "opus",
|
|
sampleRate: 48_000,
|
|
numberOfChannels: channels,
|
|
bitrate: 32_000,
|
|
});
|
|
|
|
document.body.dataset.state = "publishing";
|
|
status("publishing");
|
|
|
|
// -- Reconnect scheduler (optional) --------------------------------
|
|
// If reconnectAfterMs > 0, fire ONCE at that mark to cycle the
|
|
// session. We schedule one-shot — the test only needs to assert
|
|
// the listener recovers across a single Announce::Ended → Active.
|
|
let reconnectFired = false;
|
|
const reconnectScheduler = (async () => {
|
|
if (reconnectAfterMs <= 0) return;
|
|
await new Promise((r) => setTimeout(r, reconnectAfterMs));
|
|
if (reconnectFired) return;
|
|
reconnectFired = true;
|
|
cycleId += 1;
|
|
status(`reconnect cycle ${cycleId}: closing session`);
|
|
const oldSession = session;
|
|
// Close the current session first so the relay sees
|
|
// Announce::Ended cleanly. Then open a fresh one.
|
|
oldSession.closeAll();
|
|
try {
|
|
session = await openSession();
|
|
} catch (e) {
|
|
console.error("[publish] reconnect openSession failed", e);
|
|
return;
|
|
}
|
|
producer = new Container.Legacy.Producer(session.audioMoqTrack);
|
|
producerStarted = false;
|
|
status(`reconnect cycle ${cycleId}: published fresh session`);
|
|
(window as any).__publishCycle = cycleId;
|
|
})();
|
|
|
|
// -- Encoder feed loop --------------------------------------------
|
|
const deadline = performance.now() + durationSec * 1000;
|
|
let framesIn = 0;
|
|
while (performance.now() < deadline) {
|
|
const { done, value } = await reader.read();
|
|
if (done || !value) break;
|
|
try {
|
|
encoder.encode(value);
|
|
framesIn++;
|
|
} finally {
|
|
value.close();
|
|
}
|
|
}
|
|
status(`flushing, framesIn=${framesIn}, cycles=${cycleId}`);
|
|
|
|
try {
|
|
await encoder.flush();
|
|
} catch (e) {
|
|
console.warn("[publish] flush:", e);
|
|
}
|
|
encoder.close();
|
|
osc.stop();
|
|
audioTrack.stop();
|
|
try {
|
|
producer.close();
|
|
} catch (_) {
|
|
// ignore
|
|
}
|
|
session.closeAll();
|
|
sendDone();
|
|
void reconnectScheduler;
|
|
|
|
document.body.dataset.state = "done";
|
|
(window as any).__framesIn = framesIn;
|
|
(window as any).__publishCycle = cycleId;
|
|
status(`done. framesIn=${framesIn}, cycles=${cycleId}`);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("[publish] fatal:", e);
|
|
document.body.dataset.state = "error";
|
|
status(`ERROR: ${e?.stack ?? e}`);
|
|
});
|