diff --git a/.changeset/heavy-pugs-count.md b/.changeset/heavy-pugs-count.md
new file mode 100644
index 000000000..aa612311e
--- /dev/null
+++ b/.changeset/heavy-pugs-count.md
@@ -0,0 +1,6 @@
+---
+"@whereby.com/browser-sdk": minor
+"@whereby.com/core": minor
+---
+
+Add a headless pre-call network test: `PreCallTestClient` (via `client.getPreCallTest()`) in core, and the `usePreCallTest` hook in browser-sdk. It measures available bitrate and packet loss against the Whereby media servers before joining a room, without needing a room or local media.
diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md
index 59ec81c64..1e916bd22 100644
--- a/packages/browser-sdk/README.md
+++ b/packages/browser-sdk/README.md
@@ -66,6 +66,74 @@ function MyPreCallUX() {
}
```
+#### usePreCallTest
+
+The `usePreCallTest` hook measures the connection between the end user and the
+Whereby media servers, so you can warn people about a poor network before they
+join a room. It needs no room and no local media, and runs on its own
+connection.
+
+Because the test saturates the connection to measure it, run it *before*
+joining — running it during a call takes bandwidth away from the call.
+
+```js
+import { usePreCallTest } from "@whereby.com/browser-sdk/react";
+
+function MyNetworkCheck() {
+ const { state, actions } = usePreCallTest();
+ const { status, result, error } = state;
+
+ return (
+
+ Your connection may struggle: {result.details.recvAvailableBitrate.toFixed(1)} Mbps down,{" "}
+ {(result.details.recvLoss * 100).toFixed(1)}% packet loss.
+
+ )}
+ {status === "failed" &&
Could not test your connection: {error.message}
}
+
+ );
+}
+```
+
+The test takes 15 seconds by default; pass `{ durationSeconds }` to
+`startTest()` to change that (minimum 10 — below that there is not enough media
+to report on). `startTest()` also resolves with the same result object, if you
+prefer to await it rather than read `state`. Call `actions.stopTest()` to abort
+a run.
+
+`result.success` means no problems were found. `result.warning` means the test
+completed but the connection is degraded — the flags in `result.details` say
+which check tripped: `lowRecvAvailableBitrate` (below 1.5 Mbps), `highSendLoss`
+or `highRecvLoss` (above 3% packet loss). A test that could not produce a
+verdict at all sets `status` to `"failed"` and fills in `error`.
+
+##### Checking camera, microphone and speakers
+
+Unlike the network test, whether a camera or microphone *works* is a judgement
+only the end user can make — they have to see themselves and hear themselves.
+The SDK gives you the pieces to build that; the verdict comes from your UI:
+
+- **Camera** — render `localMedia.state.localStream` in a `` and let
+ the user switch between `cameraDevices`, then ask "can you see yourself?".
+- **Speakers** — play a sound and route it to the chosen device with
+ [`HTMLMediaElement.setSinkId()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId)
+ using `localMedia.state.currentSpeakerDeviceId`, then ask "can you hear it?".
+- **Microphone** — feed the local stream's audio track into a Web Audio
+ [`AnalyserNode`](https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode)
+ to draw a level meter, or record a few seconds with `MediaRecorder` and play
+ it back, then ask "can you hear yourself?".
+
+Device errors that *are* machine-detectable — blocked permissions, a device
+that fails to open — surface on `useLocalMedia` as `cameraDeviceError`,
+`microphoneDeviceError` and `startError`.
+
#### useRoomConnection
The `useRoomConnection` hook provides a way to connect participants in a given
diff --git a/packages/browser-sdk/src/lib/react/index.ts b/packages/browser-sdk/src/lib/react/index.ts
index 4513df6e7..c2a2248aa 100644
--- a/packages/browser-sdk/src/lib/react/index.ts
+++ b/packages/browser-sdk/src/lib/react/index.ts
@@ -2,6 +2,7 @@ export { Provider as WherebyProvider } from "./Provider";
export { VideoView } from "./VideoView";
export { useRoomConnection } from "./useRoomConnection";
export { useLocalMedia } from "./useLocalMedia";
+export { usePreCallTest } from "./usePreCallTest";
export { Grid as VideoGrid, GridCell, GridVideoView } from "./Grid";
export { MAX_FILES_PER_UPLOAD, MAX_FILE_SIZE, ACCEPTED_FILE_TYPES } from "@whereby.com/core";
export {
@@ -15,8 +16,20 @@ export { getUsableCameraEffectPresets, isAudioDenoiserSupported } from "@whereby
export type { UseLocalMediaResult } from "./useLocalMedia/types";
+export type { UsePreCallTestOptions, UsePreCallTestResult } from "./usePreCallTest/types";
+
export type { RoomConnectionActions, RoomConnectionOptions } from "./useRoomConnection/types";
+export type {
+ PreCallTestDetails,
+ PreCallTestError,
+ PreCallTestErrorReason,
+ PreCallTestOptions,
+ PreCallTestResult,
+ PreCallTestState,
+ PreCallTestStatus,
+} from "@whereby.com/core";
+
export type {
ChatMessageState as ChatMessage,
ChatFileShare,
diff --git a/packages/browser-sdk/src/lib/react/usePreCallTest/index.ts b/packages/browser-sdk/src/lib/react/usePreCallTest/index.ts
new file mode 100644
index 000000000..31f4ca651
--- /dev/null
+++ b/packages/browser-sdk/src/lib/react/usePreCallTest/index.ts
@@ -0,0 +1,55 @@
+import * as React from "react";
+import { PreCallTestOptions, PreCallTestState } from "@whereby.com/core";
+import { UsePreCallTestResult } from "./types";
+import { WherebyContext } from "../Provider";
+import { initialState } from "./initialState";
+
+/**
+ * Measures the connection between this client and the Whereby media servers, so
+ * you can warn people about a bad network before they join a room.
+ *
+ * The test is never started for you - call `actions.startTest()`. It needs no
+ * room and no local media, but it does compete for bandwidth with any call
+ * already in progress, so run it before joining.
+ */
+export function usePreCallTest(): UsePreCallTestResult {
+ const client = React.useContext(WherebyContext)?.getPreCallTest();
+ const [preCallTestState, setPreCallTestState] = React.useState(() => initialState);
+
+ if (!client) {
+ throw new Error("WherebyClient is not initialized. Please wrap your component with WherebyProvider.");
+ }
+
+ const isRunning = preCallTestState.status === "running";
+ const isRunningRef = React.useRef(isRunning);
+ isRunningRef.current = isRunning;
+
+ React.useEffect(() => {
+ // A test may already be running - started elsewhere, or before this
+ // component mounted.
+ setPreCallTestState(client.getState());
+
+ const unsubscribe = client.subscribe(setPreCallTestState);
+
+ return () => {
+ unsubscribe();
+
+ // Leave a finished test's result in place for anyone else reading it;
+ // only tear down a test that is still consuming bandwidth.
+ if (isRunningRef.current) {
+ client.stopTest();
+ }
+ };
+ }, [client]);
+
+ const startTest = React.useCallback((options?: PreCallTestOptions) => client.startTest(options), [client]);
+ const stopTest = React.useCallback(() => client.stopTest(), [client]);
+
+ return {
+ state: preCallTestState,
+ actions: {
+ startTest,
+ stopTest,
+ },
+ };
+}
diff --git a/packages/browser-sdk/src/lib/react/usePreCallTest/initialState.ts b/packages/browser-sdk/src/lib/react/usePreCallTest/initialState.ts
new file mode 100644
index 000000000..3c9ee4d7c
--- /dev/null
+++ b/packages/browser-sdk/src/lib/react/usePreCallTest/initialState.ts
@@ -0,0 +1,7 @@
+import { PreCallTestState } from "@whereby.com/core";
+
+export const initialState: PreCallTestState = {
+ status: "idle",
+ result: null,
+ error: null,
+};
diff --git a/packages/browser-sdk/src/lib/react/usePreCallTest/types.ts b/packages/browser-sdk/src/lib/react/usePreCallTest/types.ts
new file mode 100644
index 000000000..b4f3bedb8
--- /dev/null
+++ b/packages/browser-sdk/src/lib/react/usePreCallTest/types.ts
@@ -0,0 +1,16 @@
+import { PreCallTestOptions, PreCallTestResult, PreCallTestState } from "@whereby.com/core";
+
+interface PreCallTestActions {
+ /**
+ * Runs a test and resolves with its result, or with `null` if it could not
+ * complete - `state.error` says why. A call made while a test is running
+ * resolves with `null` and leaves the running test alone.
+ */
+ startTest: (options?: PreCallTestOptions) => Promise;
+ /** Aborts a running test and returns to the idle state. */
+ stopTest: () => void;
+}
+
+export type UsePreCallTestResult = { state: PreCallTestState; actions: PreCallTestActions };
+
+export type UsePreCallTestOptions = PreCallTestOptions;
diff --git a/packages/browser-sdk/src/stories/components/PreCallTestExperience.tsx b/packages/browser-sdk/src/stories/components/PreCallTestExperience.tsx
new file mode 100644
index 000000000..be0512330
--- /dev/null
+++ b/packages/browser-sdk/src/stories/components/PreCallTestExperience.tsx
@@ -0,0 +1,89 @@
+import * as React from "react";
+import { usePreCallTest } from "../../lib/react";
+import type { PreCallTestDetails, PreCallTestResult } from "../../lib/react";
+
+function Verdict({ result }: { result: PreCallTestResult }) {
+ if (result.success) {
+ return Connection looks good;
+ }
+
+ if (result.warning) {
+ return Connection is degraded;
+ }
+
+ return Connection is too poor for a call;
+}
+
+function Details({ details }: { details: PreCallTestDetails }) {
+ const rows: Array<[string, string, boolean]> = [
+ ["Test time", `${(details.testTime / 1000).toFixed(1)} s`, false],
+ [
+ "Available downstream bitrate",
+ `${details.recvAvailableBitrate.toFixed(2)} Mbps`,
+ details.lowRecvAvailableBitrate,
+ ],
+ ["Upstream packet loss", `${(details.sendLoss * 100).toFixed(1)} %`, details.highSendLoss],
+ ["Downstream packet loss", `${(details.recvLoss * 100).toFixed(1)} %`, details.highRecvLoss],
+ ];
+
+ return (
+
+ Measures the connection between this client and the Whereby media servers. It needs no room and no local
+ media, but it competes for bandwidth with any call in progress - run it before joining.
+
+
+
+
+
+
+ Status: {status}
+ {isRunning && ` (running for up to ${durationSeconds ?? 15} s)`}
+
+ {error && (
+
+ Test failed ({error.reason}): {error.message}
+
+ )}
+ {result && (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/browser-sdk/src/stories/pre-call-test.stories.tsx b/packages/browser-sdk/src/stories/pre-call-test.stories.tsx
new file mode 100644
index 000000000..eda5d731d
--- /dev/null
+++ b/packages/browser-sdk/src/stories/pre-call-test.stories.tsx
@@ -0,0 +1,133 @@
+import * as React from "react";
+
+import { Meta } from "@storybook/react-vite";
+import "./styles.css";
+import { Provider as WherebyProvider } from "../lib/react/Provider";
+import { usePreCallTest } from "../lib/react";
+import PreCallTestExperience from "./components/PreCallTestExperience";
+import VideoExperience from "./components/VideoExperience";
+
+const defaultArgs: Meta = {
+ title: "Examples/Pre-call test",
+ argTypes: {
+ durationSeconds: { control: { type: "number", min: 10 } },
+ sfuServerOverrideHost: { control: "text" },
+ roomUrl: { control: "text" },
+ displayName: { control: "text" },
+ },
+ args: {
+ durationSeconds: 15,
+ sfuServerOverrideHost: "",
+ roomUrl: process.env.STORYBOOK_ROOM,
+ displayName: "SDK",
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default defaultArgs;
+
+const roomRegEx = new RegExp(/^https:\/\/.*\/.*/);
+
+export const BandwidthTest = ({
+ durationSeconds,
+ sfuServerOverrideHost,
+}: {
+ durationSeconds?: number;
+ sfuServerOverrideHost?: string;
+}) => {
+ return (
+
+ );
+};
+
+/**
+ * The test survives the component that started it: unmounting a finished test
+ * keeps its result around for the next reader, while unmounting a running test
+ * stops it so it no longer eats bandwidth.
+ */
+export const BandwidthTestUnmount = ({
+ durationSeconds,
+ sfuServerOverrideHost,
+}: {
+ durationSeconds?: number;
+ sfuServerOverrideHost?: string;
+}) => {
+ const [isMounted, setIsMounted] = React.useState(true);
+
+ return (
+
+
+
+
+ {isMounted ? (
+
+ ) : (
+
Unmounted. Mount again to see the state the test was left in.
+ )}
+
+ );
+};
+
+/**
+ * Warn people about a bad connection before they join. Run the test first, then
+ * let them join anyway if they want to.
+ */
+export const BandwidthTestBeforeJoiningRoom = ({
+ durationSeconds,
+ roomUrl,
+ displayName,
+}: {
+ durationSeconds?: number;
+ roomUrl: string;
+ displayName?: string;
+}) => {
+ const {
+ state: { status, result },
+ actions: { startTest },
+ } = usePreCallTest();
+ const [hasJoined, setHasJoined] = React.useState(false);
+
+ if (!roomUrl || !roomUrl.match(roomRegEx)) {
+ return