diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 6f973da..5a3160d 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -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' }] ], diff --git a/docs/build-output.md b/docs/build-output.md index ef4acee..8c6dfea 100644 --- a/docs/build-output.md +++ b/docs/build-output.md @@ -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`. diff --git a/docs/examples/angular.md b/docs/examples/angular.md index 5f0bb12..d6f521f 100644 --- a/docs/examples/angular.md +++ b/docs/examples/angular.md @@ -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) @@ -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}` } } @@ -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. diff --git a/docs/examples/host-gain.md b/docs/examples/host-gain.md index 1fed88b..dc60b0b 100644 --- a/docs/examples/host-gain.md +++ b/docs/examples/host-gain.md @@ -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 diff --git a/docs/examples/nextjs.md b/docs/examples/nextjs.md index 2f4bca4..8989a64 100644 --- a/docs/examples/nextjs.md +++ b/docs/examples/nextjs.md @@ -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 @@ -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}`) } } @@ -209,21 +212,23 @@ el?.start() el?.audioContext // ✅ typed ``` -**React 19+** — `` 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 `` 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, 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, HTMLElement> & { + 'number-of-tests'?: number + 'mls-bits'?: number + 'max-lag-ms'?: number + 'recording-mode'?: 'mediarecorder' | 'mediarecorder-1ch' | 'audioworklet' + 'signal-type'?: 'mls' + 'buffer-size'?: number + } } } } diff --git a/docs/examples/react.md b/docs/examples/react.md index 00d0487..c22b83e 100644 --- a/docs/examples/react.md +++ b/docs/examples/react.md @@ -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 @@ -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}`) } } @@ -132,20 +135,22 @@ el?.start() el?.audioContext // ✅ typed ``` -**React 19+** — `` 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 — `` 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, 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, HTMLElement> & { + 'number-of-tests'?: number + 'mls-bits'?: number + 'max-lag-ms'?: number + 'recording-mode'?: 'mediarecorder' | 'mediarecorder-1ch' | 'audioworklet' + 'signal-type'?: 'mls' + } } } } diff --git a/docs/examples/svelte.md b/docs/examples/svelte.md index b6a48ff..7eddfa1 100644 --- a/docs/examples/svelte.md +++ b/docs/examples/svelte.md @@ -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) @@ -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}` } } @@ -70,7 +73,7 @@ The recommended pattern is a two-step flow: connect audio first, then run tests. }) - + {#if !isConnected} @@ -121,7 +124,7 @@ When your application already owns a mic stream and `AudioContext`, pass both to $: if (lt && inputStream) lt.inputStream = inputStream - + ``` --- @@ -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) } } {#if browser && isReady} - + {#if !isConnected} {:else} @@ -193,7 +198,7 @@ Types ship with the package. Import `LatencyTestElement` directly: import type { LatencyTestElement } from '@adasp/latency-test' let lt: LatencyTestElement -// +// lt?.start() lt?.audioContext // ✅ typed diff --git a/docs/examples/vanilla-js.md b/docs/examples/vanilla-js.md index 12a353a..5c53e93 100644 --- a/docs/examples/vanilla-js.md +++ b/docs/examples/vanilla-js.md @@ -16,7 +16,13 @@ The recommended pattern is a two-step flow: connect audio first, then run tests. - + Latency Test Demo + @@ -29,7 +35,7 @@ The recommended pattern is a two-step flow: connect audio first, then run tests. -