Skip to content
Merged
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
8 changes: 8 additions & 0 deletions docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ export default defineConfig({
description: 'Web Component for measuring browser round-trip audio latency using an MLS signal.',
base: '/latency-test/',

// vite/esbuild are overridden past vitepress's declared range to clear esbuild/vite
// CVEs (see package.json "overrides"); es2020 sidesteps an esbuild 0.28.x regression
// that can't downlevel destructuring for the older browser targets vite computes by default.
vite: {
build: { target: 'es2020' },
optimizeDeps: { esbuildOptions: { target: 'es2020' } }
},

head: [
['link', { rel: 'icon', href: '/latency-test/favicon.ico' }]
],
Expand Down
2 changes: 1 addition & 1 deletion docs/build-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

| File | Purpose |
|------|---------|
| `latency-test.legacy.esm.js` | Legacy ESM — private fields, optional chaining, and nullish coalescing lowered |
| `latency-test.legacy.esm.js` | Legacy ESM — private fields, optional chaining, and nullish coalescing lowered; reachable via `import '@adasp/latency-test/legacy'` |
| `latency-test.legacy.iife.js` | Legacy IIFE — same lowering; used by the demo page |

`npm run build:component:all` runs both builds in sequence and is used by CI and `prepublishOnly`.
Expand Down
59 changes: 59 additions & 0 deletions docs/examples/angular.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export class LatencyTesterComponent implements AfterViewInit, OnDestroy {
}

async connect() {
this.error = null
try {
this.audioCtx = new AudioContext({ latencyHint: 0 })
this.micStream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS)
Expand All @@ -101,6 +102,8 @@ export class LatencyTesterComponent implements AfterViewInit, OnDestroy {
} catch (e: any) {
this.micStream?.getTracks().forEach(t => t.stop())
this.micStream = null
await this.audioCtx?.close()
this.audioCtx = null
this.error = `Could not access mic: ${e.message}`
}
}
Expand All @@ -114,6 +117,62 @@ export class LatencyTesterComponent implements AfterViewInit, OnDestroy {

---

## Zoneless apps (Angular CLI 22+ default)

Angular CLI 22 scaffolds a fully zoneless app by default — zone.js is not included. The `connect()` example above relies on change detection running after `await getUserMedia()` resolves and inside the CustomEvent listeners (`onResult`, `onComplete`, `onError`). Without zone.js, these updates won't reliably reach the template.

Install zone.js and re-enable it explicitly:

```bash
npm install zone.js --save
```

Load the polyfill before bootstrap — `provideZoneChangeDetection` has nothing to patch until zone.js itself is loaded. Add it to `main.ts`, before the `bootstrapApplication` call:

```ts
// main.ts
import 'zone.js'
import { bootstrapApplication } from '@angular/platform-browser'
```

(Equivalent: add `"polyfills": ["zone.js"]` to the `build` target options in `angular.json` instead of the manual import.)

```ts
// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'

export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
// ...other providers
]
}
```

Even with zone.js re-enabled, call `ChangeDetectorRef.markForCheck()` explicitly after `await getUserMedia()` and inside the CustomEvent callbacks — zone.js does not reliably trigger change detection for these paths on its own:

```ts
import { ChangeDetectorRef, inject } from '@angular/core'

export class LatencyTesterComponent implements AfterViewInit, OnDestroy {
private cdr = inject(ChangeDetectorRef)

async connect() {
try {
// ...
this.isConnected = true
this.cdr.markForCheck()
} catch (e: any) {
// ...
this.error = `Could not access mic: ${e.message}`
this.cdr.markForCheck()
}
}
}
```

---

## Sharing audio resources from a host app

When your application already owns a mic stream and `AudioContext`, pass both to the element. The component will not stop the stream or close the context — the host owns both lifetimes.
Expand Down
22 changes: 14 additions & 8 deletions docs/examples/host-gain.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,20 @@ This pattern introduces an extra browser-managed Web Audio / MediaStream bridge.

```js
const ac = new AudioContext({ latencyHint: 0 })
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
channelCount: 1
}
})
let stream
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
channelCount: 1
}
})
} catch (e) {
await ac.close()
throw e
}

// Build the gain chain.
// Adjust gainValue as needed — 50 is a starting point for Safari >= 16
Expand Down
29 changes: 17 additions & 12 deletions docs/examples/nextjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function LatencyTester({ numberOfTests = 5 }: { numberOfTests?: number })
useLatencyTest(onReady)

async function connect() {
setError(null)
try {
const ac = new AudioContext({ latencyHint: 0 })
audioCtxRef.current = ac
Expand All @@ -70,6 +71,8 @@ export function LatencyTester({ numberOfTests = 5 }: { numberOfTests?: number })
} catch (e: any) {
micStreamRef.current?.getTracks().forEach(t => t.stop())
micStreamRef.current = null
await audioCtxRef.current?.close()
audioCtxRef.current = null
setError(`Could not access mic: ${e.message}`)
}
}
Expand Down Expand Up @@ -209,21 +212,23 @@ el?.start()
el?.audioContext // ✅ typed
```

**React 19+** — `<latency-test>` in JSX picks up types from `HTMLElementTagNameMap` automatically. No manual declarations needed.

**React < 19** (most current Next.js projects) — add a JSX namespace declaration:
A JSX namespace declaration is required regardless of React version — verified against Next.js 16 + `@types/react` 19.2.17, where `<latency-test>` in JSX does **not** automatically pick up types from `HTMLElementTagNameMap`. Declare it under `declare module 'react'` (a bare `declare namespace JSX { ... }` targets the wrong namespace in React 19 and has no effect):

```ts
// types/custom-elements.d.ts
declare namespace JSX {
interface IntrinsicElements {
'latency-test': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
'number-of-tests'?: number
'mls-bits'?: number
'max-lag-ms'?: number
'recording-mode'?: 'mediarecorder' | 'mediarecorder-1ch' | 'audioworklet'
'signal-type'?: 'mls'
'buffer-size'?: number
import type {} from 'react'

declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'latency-test': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
'number-of-tests'?: number
'mls-bits'?: number
'max-lag-ms'?: number
'recording-mode'?: 'mediarecorder' | 'mediarecorder-1ch' | 'audioworklet'
'signal-type'?: 'mls'
'buffer-size'?: number
}
}
}
}
Expand Down
27 changes: 16 additions & 11 deletions docs/examples/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function LatencyTester({ numberOfTests = 5 }) {
const [error, setError] = useState(null)

async function connect() {
setError(null)
try {
const ac = new AudioContext({ latencyHint: 0 })
audioCtxRef.current = ac
Expand All @@ -52,6 +53,8 @@ export function LatencyTester({ numberOfTests = 5 }) {
} catch (e) {
micStreamRef.current?.getTracks().forEach(t => t.stop())
micStreamRef.current = null
await audioCtxRef.current?.close()
audioCtxRef.current = null
setError(`Could not access mic: ${e.message}`)
}
}
Expand Down Expand Up @@ -132,20 +135,22 @@ el?.start()
el?.audioContext // ✅ typed
```

**React 19+** — `<latency-test>` in JSX picks up types from `HTMLElementTagNameMap` automatically. No manual declarations needed.

**React < 19** — add a JSX namespace declaration to avoid template type errors:
A JSX namespace declaration is required regardless of React version — `<latency-test>` in JSX does **not** automatically pick up types from `HTMLElementTagNameMap`. Declare it under `declare module 'react'` (a bare `declare namespace JSX { ... }` targets the wrong namespace in React 19 and has no effect):

```ts
// src/custom-elements.d.ts
declare namespace JSX {
interface IntrinsicElements {
'latency-test': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
'number-of-tests'?: number
'mls-bits'?: number
'max-lag-ms'?: number
'recording-mode'?: 'mediarecorder' | 'mediarecorder-1ch' | 'audioworklet'
'signal-type'?: 'mls'
import type {} from 'react'

declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'latency-test': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
'number-of-tests'?: number
'mls-bits'?: number
'max-lag-ms'?: number
'recording-mode'?: 'mediarecorder' | 'mediarecorder-1ch' | 'audioworklet'
'signal-type'?: 'mls'
}
}
}
}
Expand Down
13 changes: 9 additions & 4 deletions docs/examples/svelte.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ The recommended pattern is a two-step flow: connect audio first, then run tests.
let error = null

async function connect() {
error = null
try {
audioCtx = new AudioContext({ latencyHint: 0 })
micStream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS)
Expand All @@ -47,6 +48,8 @@ The recommended pattern is a two-step flow: connect audio first, then run tests.
} catch (e) {
micStream?.getTracks().forEach(t => t.stop())
micStream = null
await audioCtx?.close()
audioCtx = null
error = `Could not access mic: ${e.message}`
}
}
Expand All @@ -70,7 +73,7 @@ The recommended pattern is a two-step flow: connect audio first, then run tests.
})
</script>

<latency-test bind:this={lt} number-of-tests="5" />
<latency-test bind:this={lt} number-of-tests="5"></latency-test>

{#if !isConnected}
<button on:click={connect}>Connect Audio</button>
Expand Down Expand Up @@ -121,7 +124,7 @@ When your application already owns a mic stream and `AudioContext`, pass both to
$: if (lt && inputStream) lt.inputStream = inputStream
</script>

<latency-test bind:this={lt} />
<latency-test bind:this={lt}></latency-test>
```

---
Expand Down Expand Up @@ -168,13 +171,15 @@ SvelteKit runs components on the server during SSR. Custom elements that access
} catch (e) {
micStream?.getTracks().forEach(t => t.stop())
micStream = null
await audioCtx?.close()
audioCtx = null
console.error('Could not access mic:', e.message)
}
}
</script>

{#if browser && isReady}
<latency-test bind:this={lt} />
<latency-test bind:this={lt}></latency-test>
{#if !isConnected}
<button on:click={connect}>Connect Audio</button>
{:else}
Expand All @@ -193,7 +198,7 @@ Types ship with the package. Import `LatencyTestElement` directly:
import type { LatencyTestElement } from '@adasp/latency-test'

let lt: LatencyTestElement
// <latency-test bind:this={lt} />
// <latency-test bind:this={lt}></latency-test>

lt?.start()
lt?.audioContext // ✅ typed
Expand Down
13 changes: 11 additions & 2 deletions docs/examples/vanilla-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ The recommended pattern is a two-step flow: connect audio first, then run tests.
<!DOCTYPE html>
<html lang="en">
<head>
<script type="module" src="https://cdn.jsdelivr.net/npm/@adasp/latency-test@1.2.0/dist/latency-test.esm.js"></script>
<title>Latency Test Demo</title>
<script
type="module"
src="https://cdn.jsdelivr.net/npm/@adasp/latency-test@1.2.0/dist/latency-test.esm.js"
integrity="sha384-9dXVEFJFXcDEQz2sxPKrgnGNF+GTVBkoD5sxQF49woQKjJ7fb6xrhZ5cTb+Hk4e+"
crossorigin="anonymous"
></script>
</head>
<body>
<button id="connect-btn">Connect Audio</button>
Expand All @@ -29,7 +35,7 @@ The recommended pattern is a two-step flow: connect audio first, then run tests.

<latency-test id="lt" number-of-tests="5"></latency-test>

<script>
<script type="module">
const lt = document.getElementById('lt')
const connectBtn = document.getElementById('connect-btn')
const testUi = document.getElementById('test-ui')
Expand All @@ -46,6 +52,7 @@ The recommended pattern is a two-step flow: connect audio first, then run tests.

connectBtn.addEventListener('click', async () => {
connectBtn.disabled = true
result.textContent = ''
try {
audioCtx = new AudioContext({ latencyHint: 0 })
micStream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS)
Expand All @@ -56,6 +63,8 @@ The recommended pattern is a two-step flow: connect audio first, then run tests.
} catch (e) {
micStream?.getTracks().forEach(t => t.stop())
micStream = null
await audioCtx?.close()
audioCtx = null
connectBtn.disabled = false
result.textContent = `Could not access mic: ${e.message}`
}
Expand Down
3 changes: 3 additions & 0 deletions docs/examples/vue.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const stats = ref(null)
const error = ref(null)

async function connect() {
error.value = null
try {
audioCtx.value = new AudioContext({ latencyHint: 0 })
micStream.value = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS)
Expand All @@ -69,6 +70,8 @@ async function connect() {
} catch (e) {
micStream.value?.getTracks().forEach(t => t.stop())
micStream.value = null
await audioCtx.value?.close()
audioCtx.value = null
error.value = `Could not access mic: ${e.message}`
}
}
Expand Down
17 changes: 12 additions & 5 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,18 @@ npm install @adasp/latency-test

// audioContext and inputStream must be assigned before start() — create them from a user gesture
document.getElementById('btn').addEventListener('click', async () => {
if (!lt.audioContext) {
lt.audioContext = new AudioContext({ latencyHint: 0 })
lt.inputStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }
})
if (!lt.inputStream) {
const ac = new AudioContext({ latencyHint: 0 })
try {
lt.inputStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }
})
lt.audioContext = ac
} catch (e) {
await ac.close()
console.error('Could not access mic:', e.message)
return
}
}
lt.start()
})
Expand Down
Loading