Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`checkAvatarConnection()`** — preflight WebSocket/WebRTC checks using LiveKit `ConnectionCheck`, with normalized results (no LiveKit types in the public API).
- **`useConnectionQuality()`** and **`ConnectionIndicator`** — composable in-call warning when the local network is degraded or reconnecting (debounced; local participant only).
- **`useConnectionCheck()`** and **`AvatarCall` `connectionCheck` prop** — optional silent background preflight (no UI; in-call warnings use `ConnectionIndicator`).
- **`session.connectionQuality`** getter on core `AvatarSession`.

### Fixed

- **`ConnectionQualityChanged`** in core now only reflects the **local** participant (avoids avatar quality updates being shown as the user's network issue).
- **Preflight `checkAvatarConnection`** defaults to WebSocket-only (no full WebRTC join on the session token before the call).
- **`useConnectionQuality`** treats LiveKit **`signalReconnecting`** like reconnecting for the in-call warning.
- **`AvatarSession` state** maps LiveKit **`signalReconnecting`** to `reconnecting` (was incorrectly treated as `ended`).
- **`ConnectionIndicator`** also uses inbound WebRTC stats (RTT / packet loss) when LiveKit still reports `good`, so slow links (e.g. DevTools 3G) can surface **Slow connection**.
- **`ConnectionQualityDevTools`** and **`connectionDebug`** / **`connectionPreviewWarning`** on `AvatarCall` — opt-in dev overlay (metrics top-right; does not affect control bar layout).
- Warning pill includes an amber **signal** dot; reconnecting uses a slightly warmer tone.

## [0.16.0] - 2026-05-12

### Added
Expand Down
58 changes: 54 additions & 4 deletions Justfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# Run `just` or `just --list` to see recipes. Requires https://github.com/casey/just

default:
@just --list

# Build
build: build-core build-react
build-core:
Expand All @@ -15,15 +20,60 @@ typecheck: build-core
cd packages/react && bun run typecheck
lint:
bun run lint
verify: typecheck lint test build

# Dev
# Dev (packages)
dev-core:
cd packages/core && bun run dev
dev-react: build-core
cd packages/react && bun run dev

# Examples
# Link workspace packages for local example development (run once per clone if needed)
link-packages:
cd packages/core && bun link
cd packages/react && bun link

playground: build link-packages
cd playground && bun install && bun link @runwayml/avatars @runwayml/avatars-react && bun run dev

# Examples — build SDK, link workspace packages, install, then start dev server.
# Core-only examples link @runwayml/avatars; React examples link both packages.

vanilla-js: build-core
cd examples/vanilla-js && bun install && bun run dev
cd examples/vanilla-js && bun install && bun link @runwayml/avatars && bun run dev

sveltekit: build-core
cd examples/sveltekit && bun install && bun run dev
cd examples/sveltekit && bun install && bun link @runwayml/avatars && bun run dev

dev-express: build link-packages
cd examples/express && bun install && bun link @runwayml/avatars @runwayml/avatars-react && bun run dev

dev-nextjs: build link-packages
cd examples/nextjs && bun install && bun link @runwayml/avatars @runwayml/avatars-react && bun run dev

dev-nextjs-simple: build link-packages
cd examples/nextjs-simple && bun install && bun link @runwayml/avatars @runwayml/avatars-react && bun run dev

dev-nextjs-client-events: build link-packages
cd examples/nextjs-client-events && bun install && bun link @runwayml/avatars @runwayml/avatars-react && bun run dev

dev-nextjs-server-actions: build link-packages
cd examples/nextjs-server-actions && bun install && bun link @runwayml/avatars @runwayml/avatars-react && bun run dev

dev-nextjs-rpc: build link-packages
cd examples/nextjs-rpc && bun install && bun link @runwayml/avatars @runwayml/avatars-react && bun run dev

dev-nextjs-rpc-external-api: build link-packages
cd examples/nextjs-rpc-external-api && bun install && bun link @runwayml/avatars @runwayml/avatars-react && bun run dev

dev-nextjs-rpc-weather: build link-packages
cd examples/nextjs-rpc-weather && bun install && bun link @runwayml/avatars @runwayml/avatars-react && bun run dev

dev-subtitles: build link-packages
cd examples/subtitles && bun install && bun link @runwayml/avatars @runwayml/avatars-react && bun run dev

dev-react-router: build link-packages
cd examples/react-router && bun install && bun link @runwayml/avatars @runwayml/avatars-react && bun run dev

elevenlabs: build link-packages
cd examples/nextjs-elevenlabs && bun install && bun link @runwayml/avatars @runwayml/avatars-react && bun run dev
28 changes: 27 additions & 1 deletion examples/vanilla-js/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,25 @@
<button id="end" disabled>End call</button>
</div>
<p id="status">Ready</p>
<p id="connection-warning" hidden></p>
</main>

<script type="module">
import './styles.css';
import { streamTo, AvatarEvent } from '@runwayml/avatars';
import {
streamTo,
AvatarEvent,
connectionQualityWarningMessage,
isDegradedConnectionQuality,
} from '@runwayml/avatars';

const avatarEl = document.getElementById('avatar');
const webcamEl = document.getElementById('webcam');
const startBtn = document.getElementById('start');
const muteBtn = document.getElementById('mute');
const endBtn = document.getElementById('end');
const statusEl = document.getElementById('status');
const connectionWarningEl = document.getElementById('connection-warning');

async function fetchCredentials() {
const res = await fetch('/api/avatar/connect', {
Expand All @@ -44,6 +51,7 @@
startBtn.disabled = true;

const credentials = await fetchCredentials();

const session = await streamTo({ credentials, target: avatarEl });

session.on(AvatarEvent.AvatarVideoReady, () => {
Expand All @@ -69,6 +77,23 @@
console.error('Session error:', err);
});

session.on(AvatarEvent.ConnectionQualityChanged, (quality) => {
const message = connectionQualityWarningMessage(quality);
if (message && isDegradedConnectionQuality(quality)) {
connectionWarningEl.hidden = false;
connectionWarningEl.textContent = message;
} else if (!isDegradedConnectionQuality(quality)) {
connectionWarningEl.hidden = true;
}
});

session.on(AvatarEvent.StateChanged, (state) => {
if (state === 'reconnecting') {
connectionWarningEl.hidden = false;
connectionWarningEl.textContent = 'Reconnecting…';
}
});

muteBtn.disabled = false;
endBtn.disabled = false;

Expand All @@ -78,6 +103,7 @@
session.end();
webcamEl.srcObject = null;
statusEl.textContent = 'Ended';
connectionWarningEl.hidden = true;
startBtn.disabled = false;
muteBtn.disabled = true;
endBtn.disabled = true;
Expand Down
7 changes: 7 additions & 0 deletions examples/vanilla-js/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,10 @@ button:disabled {
color: #555;
letter-spacing: 0.02em;
}

#connection-warning {
font-size: 0.8125rem;
color: #e8c47c;
text-align: center;
max-width: 100%;
}
17 changes: 17 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ session.on(AvatarEvent.Error, (error) => {});
session.on(AvatarEvent.AvatarVideoReady, (track) => {});
session.on(AvatarEvent.AvatarAudioReady, (track) => {});
session.on(AvatarEvent.MediaChanged, () => {});
session.on(AvatarEvent.ConnectionQualityChanged, (quality) => {
// excellent | good | poor | lost | unknown (local participant only)
});
```

### Connection preflight

```javascript
import { checkAvatarConnection } from '@runwayml/avatars';

const result = await checkAvatarConnection({
serverUrl: credentials.serverUrl,
token: credentials.token,
});
if (!result.success) {
console.warn('Network check failed', result.checks);
}
```

### Error handling
Expand Down
19 changes: 17 additions & 2 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function toSessionState(cs: ConnectionState): SessionState {
case ConnectionState.Connected:
return 'active';
case ConnectionState.Reconnecting:
case ConnectionState.SignalReconnecting:
return 'reconnecting';
case ConnectionState.Disconnected:
return 'ended';
Expand Down Expand Up @@ -89,6 +90,7 @@ export class AvatarSession extends Emitter<AvatarEventMap> {
private _userSpeaking = false;
private _avatarSpeaking = false;
private _connectedAt: number | null = null;
private _connectionQuality: ConnectionQuality = 'unknown';

readonly mic: MediaController;
readonly camera: MediaController;
Expand Down Expand Up @@ -151,6 +153,11 @@ export class AvatarSession extends Emitter<AvatarEventMap> {
return Date.now() - this._connectedAt;
}

/** Local participant connection quality (from LiveKit). */
get connectionQuality(): ConnectionQuality {
return this._connectionQuality;
}

waitFor<K extends keyof AvatarEventMap>(event: K): Promise<AvatarEventMap[K][0]> {
return new Promise((resolve) => {
this.once(event, ((...args: AvatarEventMap[K]) => {
Expand Down Expand Up @@ -426,10 +433,17 @@ export class AvatarSession extends Emitter<AvatarEventMap> {

room.on(
RoomEvent.ConnectionQualityChanged,
(quality: LKConnectionQuality, _participant: Participant) => {
this.emit(AvatarEvent.ConnectionQualityChanged, toLKQuality(quality));
(quality: LKConnectionQuality, participant: Participant) => {
if (!participant.isLocal) {
return;
}
const mapped = toLKQuality(quality);
this._connectionQuality = mapped;
this.emit(AvatarEvent.ConnectionQualityChanged, mapped);
},
);

this._connectionQuality = toLKQuality(room.localParticipant.connectionQuality);
}

private reattachTracks(): void {
Expand Down Expand Up @@ -611,6 +625,7 @@ export class AvatarSession extends Emitter<AvatarEventMap> {
this._userSpeaking = false;
this._avatarSpeaking = false;
this._connectedAt = null;
this._connectionQuality = 'unknown';
this.flatDeltaAcc.reset();
this.setState('ended');
}
Expand Down
86 changes: 86 additions & 0 deletions packages/core/src/connection-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
CheckStatus,
ConnectionCheck,
type CheckInfo,
} from 'livekit-client';

export type ConnectionCheckStatus =
| 'idle'
| 'running'
| 'skipped'
| 'success'
| 'failed';

export interface ConnectionCheckStep {
name: string;
description: string;
status: ConnectionCheckStatus;
logs: ReadonlyArray<{ level: 'info' | 'warning' | 'error'; message: string }>;
}

export interface AvatarConnectionCheckOptions {
serverUrl: string;
token: string;
/**
* Also run a full WebRTC room join after the WebSocket check.
* Default false — WebRTC preflight uses the same session token as the call and can
* leave the room in a bad state before AvatarSession connects.
*/
webrtc?: boolean;
}

export interface AvatarConnectionCheckResult {
success: boolean;
checks: ReadonlyArray<ConnectionCheckStep>;
}

function mapCheckStatus(status: CheckStatus): ConnectionCheckStatus {
switch (status) {
case CheckStatus.IDLE:
return 'idle';
case CheckStatus.RUNNING:
return 'running';
case CheckStatus.SKIPPED:
return 'skipped';
case CheckStatus.SUCCESS:
return 'success';
case CheckStatus.FAILED:
return 'failed';
default:
return 'idle';
}
}

function mapCheckInfo(info: CheckInfo): ConnectionCheckStep {
return {
name: info.name,
description: info.description,
status: mapCheckStatus(info.status),
logs: info.logs.map((log) => ({
level: log.level,
message: log.message,
})),
};
}

/**
* Preflight connectivity checks against a LiveKit server using session credentials.
* Run before joining an avatar session (same `serverUrl` + `token` as connect).
*/
export async function checkAvatarConnection(
options: AvatarConnectionCheckOptions,
): Promise<AvatarConnectionCheckResult> {
const { serverUrl, token, webrtc = false } = options;
const checker = new ConnectionCheck(serverUrl, token);

await checker.checkWebsocket();
if (webrtc) {
await checker.checkWebRTC();
}

const checks = checker.getResults().map(mapCheckInfo);
return {
success: checker.isSuccess(),
checks,
};
}
Loading
Loading