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
5 changes: 3 additions & 2 deletions js/hang/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@
"release": "tsx ../scripts/release.ts"
},
"dependencies": {
"@huggingface/transformers": "^3.7.0",
"@kixelated/moq": "workspace:^",
"@kixelated/signals": "workspace:^",
"zod": "^4.0.0",
"async-mutex": "^0.5.0",
"comlink": "^4.4.2",
"@huggingface/transformers": "^3.7.0"
"zod": "^4.0.0"
},
"devDependencies": {
"@types/audioworklet": "^0.0.77",
Expand Down
8 changes: 6 additions & 2 deletions js/hang/src/publish/audio/captions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import * as Moq from "@kixelated/moq";
import { Effect, Signal } from "@kixelated/signals";
import type * as Catalog from "../../catalog";
import { u8 } from "../../catalog";
import { loadAudioWorklet } from "../../util/hacks";
import type { Audio } from ".";
import type { Request, Result } from "./captions-worker";
import CaptureWorklet from "./capture-worklet?worker&url";

export type CaptionsProps = {
enabled?: boolean;
Expand Down Expand Up @@ -93,7 +93,11 @@ export class Captions {

// The workload needs to be loaded asynchronously, unfortunately, but it should be instant.
effect.spawn(async () => {
await ctx.audioWorklet.addModule(CaptureWorklet);
await ctx.audioWorklet.addModule(
await loadAudioWorklet(() =>
navigator.serviceWorker.register(new URL("./capture-worklet", import.meta.url)),
),
);

// Create the worklet.
const worklet = new AudioWorkletNode(ctx, "capture", {
Expand Down
13 changes: 8 additions & 5 deletions js/hang/src/publish/audio/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,16 @@ import { Effect, type Getter, Signal } from "@kixelated/signals";
import type * as Catalog from "../../catalog";
import { u8, u53 } from "../../catalog/integers";
import * as Container from "../../container";
import { loadAudioWorklet } from "../../util/hacks";
import { Captions, type CaptionsProps } from "./captions";
import type * as Capture from "./capture";
import { Speaking, type SpeakingProps } from "./speaking";

export * from "./captions";

const GAIN_MIN = 0.001;
const FADE_TIME = 0.2;

// Unfortunately, we need to use a Vite-exclusive import for now.
import CaptureWorklet from "./capture-worklet?worker&url";
import { Speaking, type SpeakingProps } from "./speaking";

export type AudioConstraints = Omit<
MediaTrackConstraints,
"aspectRatio" | "backgroundBlur" | "displaySurface" | "facingMode" | "frameRate" | "height" | "width"
Expand Down Expand Up @@ -132,7 +130,12 @@ export class Audio {

// Async because we need to wait for the worklet to be registered.
effect.spawn(async () => {
await context.audioWorklet.addModule(CaptureWorklet);
await context.audioWorklet.addModule(
await loadAudioWorklet(() =>
navigator.serviceWorker.register(new URL("./capture-worklet", import.meta.url)),
),
);
Comment on lines +133 to +137
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Adopt the guarded loadAudioWorklet(url, registerFn) and compute URL once

Prevents crashes on platforms without Service Worker support while preserving the bundler hint.

Apply:

-      await context.audioWorklet.addModule(
-        await loadAudioWorklet(() =>
-          navigator.serviceWorker.register(new URL("./capture-worklet", import.meta.url)),
-        ),
-      );
+      const workletUrl = new URL("./capture-worklet", import.meta.url);
+      await context.audioWorklet.addModule(
+        await loadAudioWorklet(workletUrl, () => navigator.serviceWorker.register(workletUrl)),
+      );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await context.audioWorklet.addModule(
await loadAudioWorklet(() =>
navigator.serviceWorker.register(new URL("./capture-worklet", import.meta.url)),
),
);
// Compute the worklet URL once for bundler hints and reuse
const workletUrl = new URL("./capture-worklet", import.meta.url);
await context.audioWorklet.addModule(
await loadAudioWorklet(
workletUrl,
() => navigator.serviceWorker.register(workletUrl),
),
);
🤖 Prompt for AI Agents
In js/hang/src/publish/audio/index.ts around lines 133-137, the current call
inlines navigator.serviceWorker.register inside loadAudioWorklet which can crash
on platforms without Service Worker support; compute the worklet URL once into a
const (e.g. const workletUrl = new URL("./capture-worklet", import.meta.url))
and then call the guarded loadAudioWorklet(workletUrl, () =>
navigator.serviceWorker?.register(workletUrl)) so the helper can check for
service worker support before invoking the register function and the bundler
still sees the URL hint.


const worklet = new AudioWorkletNode(context, "capture", {
numberOfInputs: 1,
numberOfOutputs: 0,
Expand Down
8 changes: 6 additions & 2 deletions js/hang/src/publish/audio/speaking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { Effect, Signal } from "@kixelated/signals";
import type * as Catalog from "../../catalog";
import { u8 } from "../../catalog";
import { BoolProducer } from "../../container/bool";
import { loadAudioWorklet } from "../../util/hacks";
import type { Audio } from ".";
import CaptureWorklet from "./capture-worklet?worker&url";
import type { Request, Result } from "./speaking-worker";

export type SpeakingProps = {
Expand Down Expand Up @@ -85,7 +85,11 @@ export class Speaking {

// The workload needs to be loaded asynchronously, unfortunately, but it should be instant.
effect.spawn(async () => {
await ctx.audioWorklet.addModule(CaptureWorklet);
await ctx.audioWorklet.addModule(
await loadAudioWorklet(() =>
navigator.serviceWorker.register(new URL("./capture-worklet", import.meta.url)),
),
);

// Create the worklet.
const worklet = new AudioWorkletNode(ctx, "capture", {
Expand Down
5 changes: 1 addition & 4 deletions js/hang/src/publish/video/detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import * as Comlink from "comlink";
import * as Catalog from "../../catalog";
import type { Video } from ".";
import type { DetectionWorker } from "./detection-worker";
// Vite-specific import for worker
import WorkerUrl from "./detection-worker?worker&url";

export type DetectionProps = {
enabled?: boolean;
Expand Down Expand Up @@ -52,8 +50,7 @@ export class Detection {
track: { name: this.#track.name, priority: Catalog.u8(this.#track.priority) },
});

// Initialize worker
const worker = new Worker(WorkerUrl, { type: "module" });
const worker = new Worker(new URL("./detection-worker", import.meta.url), { type: "module" });
effect.cleanup(() => worker.terminate());

const api = Comlink.wrap<DetectionWorker>(worker);
Expand Down
2 changes: 1 addition & 1 deletion js/hang/src/support/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ export default class HangSupport extends HTMLElement {
if (mode !== "publish") {
addRow("Rendering", "Audio", binary(support.audio.render));
addRow("", "Video", binary(support.video.render));
addRow("Decoding", "Audio", binary(support.audio.decoding?.opus));
addRow("Decoding", "Opus", binary(support.audio.decoding?.opus));
addRow("", "AAC", binary(support.audio.decoding?.aac));
addRow("", "AV1", hardware(support.video.decoding?.av1));
addRow("", "H.265", hardware(support.video.decoding?.h265));
Expand Down
24 changes: 24 additions & 0 deletions js/hang/src/util/hacks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
import { Mutex } from "async-mutex";

// https://issues.chromium.org/issues/40504498
export const isChrome = navigator.userAgent.toLowerCase().includes("chrome");

// https://bugzilla.mozilla.org/show_bug.cgi?id=1967793
export const isFirefox = navigator.userAgent.toLowerCase().includes("firefox");

Comment on lines 3 to +8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Top-level navigator access will crash in SSR and non-browser contexts

Accessing navigator at module load time throws in SSR (Next.js) and workers without Navigator. Gate userAgent reads.

Apply:

-// https://issues.chromium.org/issues/40504498
-export const isChrome = navigator.userAgent.toLowerCase().includes("chrome");
-
-// https://bugzilla.mozilla.org/show_bug.cgi?id=1967793
-export const isFirefox = navigator.userAgent.toLowerCase().includes("firefox");
+// https://issues.chromium.org/issues/40504498
+const ua = typeof navigator !== "undefined" && typeof navigator.userAgent === "string"
+  ? navigator.userAgent.toLowerCase()
+  : "";
+export const isChrome = ua.includes("chrome");
+
+// https://bugzilla.mozilla.org/show_bug.cgi?id=1967793
+export const isFirefox = ua.includes("firefox");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// https://issues.chromium.org/issues/40504498
export const isChrome = navigator.userAgent.toLowerCase().includes("chrome");
// https://bugzilla.mozilla.org/show_bug.cgi?id=1967793
export const isFirefox = navigator.userAgent.toLowerCase().includes("firefox");
// https://issues.chromium.org/issues/40504498
const ua = typeof navigator !== "undefined" && typeof navigator.userAgent === "string"
? navigator.userAgent.toLowerCase()
: "";
export const isChrome = ua.includes("chrome");
// https://bugzilla.mozilla.org/show_bug.cgi?id=1967793
export const isFirefox = ua.includes("firefox");
🤖 Prompt for AI Agents
In js/hang/src/util/hacks.ts around lines 3 to 8, the module reads
navigator.userAgent at top-level which will throw in SSR or non-browser
contexts; change the exports to safe, lazily-evaluated checks that first ensure
navigator (or globalThis.navigator) exists and that navigator.userAgent is a
string (e.g., typeof globalThis !== "undefined" && typeof globalThis.navigator
!== "undefined" && typeof globalThis.navigator.userAgent === "string") before
calling toLowerCase().includes(...), or export functions/getters (isChrome(),
isFirefox()) that perform this guarded check at call time so the module load
never accesses navigator in non-browser environments.

// Hacky workaround to support Webpack and Vite
// https://github.com/webpack/webpack/issues/11543#issuecomment-2045809214

// Note that Webpack needs to see `navigator.serviceWorker.register(new URL("literal"), ...)` for this to work

const loadAudioWorkletMutex = new Mutex();

export async function loadAudioWorklet(registerFn: () => Promise<ServiceWorkerRegistration>) {
return await loadAudioWorkletMutex.runExclusive(async () => {
const { register } = navigator.serviceWorker;

// @ts-ignore hack to make webpack believe that it is registering a worker
navigator.serviceWorker.register = (url: URL) => Promise.resolve(url);

try {
return (await registerFn()) as unknown as URL;
} finally {
navigator.serviceWorker.register = register;
}
});
}
Comment on lines +16 to +29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard serviceWorker presence; avoid destructuring undefined and provide a safe fallback

As written, const { register } = navigator.serviceWorker; throws when serviceWorker is undefined (HTTP, some Safari, certain embedded webviews), and calling registerFn() will then also fail. We need a fallback that still satisfies bundlers but doesn’t execute a broken call when SW is absent. Also prefer @ts-expect-error over blanket @ts-ignore.

Proposed minimal API change: accept the worklet URL upfront and keep the current registerFn for bundler visibility. If SW is missing, return the URL without evaluating registerFn. This also solves SSR cases where navigator exists at runtime but serviceWorker does not.

Apply:

-const loadAudioWorkletMutex = new Mutex();
-
-export async function loadAudioWorklet(registerFn: () => Promise<ServiceWorkerRegistration>) {
-  return await loadAudioWorkletMutex.runExclusive(async () => {
-    const { register } = navigator.serviceWorker;
-
-    // @ts-ignore hack to make webpack believe that it is registering a worker
-    navigator.serviceWorker.register = (url: URL) => Promise.resolve(url);
-
-    try {
-      return (await registerFn()) as unknown as URL;
-    } finally {
-      navigator.serviceWorker.register = register;
-    }
-  });
-}
+const loadAudioWorkletMutex = new Mutex();
+
+export async function loadAudioWorklet(
+  url: URL,
+  registerFn?: () => Promise<ServiceWorkerRegistration>,
+): Promise<URL> {
+  return await loadAudioWorkletMutex.runExclusive(async () => {
+    const nav: any = typeof navigator !== "undefined" ? navigator : undefined;
+    const sw: any = nav?.serviceWorker;
+
+    // If no Service Worker environment or no register shim is provided, use the URL directly.
+    if (!sw || typeof sw.register !== "function" || !registerFn) {
+      return url;
+    }
+
+    const originalRegister = sw.register.bind(sw);
+    // Intentionally masquerade as ServiceWorkerContainer.register for bundlers; returns the URL.
+    // The call still exists in user code so bundlers can statically detect it.
+    // @ts-expect-error: return type intentionally diverges at runtime
+    sw.register = (_url: URL) => Promise.resolve(url);
+    try {
+      await registerFn();
+      return url;
+    } finally {
+      sw.register = originalRegister;
+    }
+  });
+}

Follow-up: this requires small call-site edits (see comments in watch/publish files). I can push a commit if you’d like.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function loadAudioWorklet(registerFn: () => Promise<ServiceWorkerRegistration>) {
return await loadAudioWorkletMutex.runExclusive(async () => {
const { register } = navigator.serviceWorker;
// @ts-ignore hack to make webpack believe that it is registering a worker
navigator.serviceWorker.register = (url: URL) => Promise.resolve(url);
try {
return (await registerFn()) as unknown as URL;
} finally {
navigator.serviceWorker.register = register;
}
});
}
const loadAudioWorkletMutex = new Mutex();
export async function loadAudioWorklet(
url: URL,
registerFn?: () => Promise<ServiceWorkerRegistration>,
): Promise<URL> {
return await loadAudioWorkletMutex.runExclusive(async () => {
const nav: any = typeof navigator !== "undefined" ? navigator : undefined;
const sw: any = nav?.serviceWorker;
// If no Service Worker environment or no register shim is provided, use the URL directly.
if (!sw || typeof sw.register !== "function" || !registerFn) {
return url;
}
const originalRegister = sw.register.bind(sw);
// Intentionally masquerade as ServiceWorkerContainer.register for bundlers; returns the URL.
// The call still exists in user code so bundlers can statically detect it.
// @ts-expect-error: return type intentionally diverges at runtime
sw.register = (_url: URL) => Promise.resolve(url);
try {
await registerFn();
return url;
} finally {
sw.register = originalRegister;
}
});
}
🤖 Prompt for AI Agents
In js/hang/src/util/hacks.ts around lines 16–29, the code destructures
navigator.serviceWorker and calls registerFn unguarded which throws when
serviceWorker is undefined; change the function signature to accept the worklet
URL upfront (preserve registerFn for bundler visibility), check if
navigator?.serviceWorker exists before touching or destructuring it, and if
absent immediately return the provided URL (avoid invoking registerFn); when
stubbing navigator.serviceWorker.register use @ts-expect-error instead of
@ts-ignore, and ensure the original register property is restored in finally.

15 changes: 8 additions & 7 deletions js/hang/src/watch/audio/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import type * as Moq from "@kixelated/moq";
import { Effect, type Getter, Signal } from "@kixelated/signals";
import type * as Catalog from "../../catalog";
import * as Container from "../../container";
import { loadAudioWorklet } from "../../util/hacks";
import * as Hex from "../../util/hex";
import { Captions, type CaptionsProps } from "./captions";
import type * as Render from "./render";
import { Speaking, type SpeakingProps } from "./speaking";

export * from "./emitter";

import { Captions, type CaptionsProps } from "./captions";
import { Speaking, type SpeakingProps } from "./speaking";

export type AudioProps = {
// Enable to download the audio track.
enabled?: boolean;
Expand All @@ -24,9 +24,6 @@ export type AudioProps = {
speaking?: SpeakingProps;
};

// Unfortunately, we need to use a Vite-exclusive import for now.
import RenderWorklet from "./render-worklet?worker&url";

// Downloads audio from a track and emits it to an AudioContext.
// The user is responsible for hooking up audio to speakers, an analyzer, etc.
export class Audio {
Expand Down Expand Up @@ -94,7 +91,11 @@ export class Audio {

effect.spawn(async () => {
// Register the AudioWorklet processor
await context.audioWorklet.addModule(RenderWorklet);
await context.audioWorklet.addModule(
await loadAudioWorklet(() =>
navigator.serviceWorker.register(new URL("./render-worklet", import.meta.url)),
),
);
Comment on lines 93 to +98
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use the safer loadAudioWorklet(url, registerFn) form and avoid duplicate URL construction

This avoids executing a register call when Service Workers are unavailable and keeps the bundler-visible pattern. It also reduces duplication.

Apply:

-      // Register the AudioWorklet processor
-      await context.audioWorklet.addModule(
-        await loadAudioWorklet(() =>
-          navigator.serviceWorker.register(new URL("./render-worklet", import.meta.url)),
-        ),
-      );
+      // Register the AudioWorklet processor
+      const workletUrl = new URL("./render-worklet", import.meta.url);
+      await context.audioWorklet.addModule(
+        await loadAudioWorklet(workletUrl, () => navigator.serviceWorker.register(workletUrl)),
+      );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Register the AudioWorklet processor
await context.audioWorklet.addModule(RenderWorklet);
await context.audioWorklet.addModule(
await loadAudioWorklet(() =>
navigator.serviceWorker.register(new URL("./render-worklet", import.meta.url)),
),
);
// Register the AudioWorklet processor
const workletUrl = new URL("./render-worklet", import.meta.url);
await context.audioWorklet.addModule(
await loadAudioWorklet(workletUrl, () => navigator.serviceWorker.register(workletUrl)),
);
🤖 Prompt for AI Agents
In js/hang/src/watch/audio/index.ts around lines 93–98, replace the inline
register-call form with the safer loadAudioWorklet(url, registerFn) pattern:
construct the worklet module URL once (e.g. new URL("./render-worklet",
import.meta.url) stored in a variable) and pass that URL as the first argument
to loadAudioWorklet and a register function that calls
navigator.serviceWorker.register with that same URL as the second argument; this
avoids executing registration when Service Workers are unavailable and
eliminates duplicate URL construction.


// Create the worklet node
const worklet = new AudioWorkletNode(context, "render");
Expand Down
5 changes: 3 additions & 2 deletions js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
"concurrently": "^9.1.2",
"cpy-cli": "^5.0.0",
"knip": "^5.60.2",
"prettier": "^3.6.2",
"rimraf": "^6.0.1",
"typescript": "^5.8.3",
"tsx": "^4.20.1"
"tsx": "^4.20.1",
"typescript": "^5.8.3"
},
"packageManager": "[email protected]"
}
13 changes: 13 additions & 0 deletions js/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading