diff --git a/js/hang/package.json b/js/hang/package.json index cdc268bd8..31e1358a4 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -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", diff --git a/js/hang/src/publish/audio/captions.ts b/js/hang/src/publish/audio/captions.ts index f7c45d44e..6de73c807 100644 --- a/js/hang/src/publish/audio/captions.ts +++ b/js/hang/src/publish/audio/captions.ts @@ -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; @@ -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", { diff --git a/js/hang/src/publish/audio/index.ts b/js/hang/src/publish/audio/index.ts index 23cbc7c69..ac30dbdf4 100644 --- a/js/hang/src/publish/audio/index.ts +++ b/js/hang/src/publish/audio/index.ts @@ -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" @@ -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)), + ), + ); + const worklet = new AudioWorkletNode(context, "capture", { numberOfInputs: 1, numberOfOutputs: 0, diff --git a/js/hang/src/publish/audio/speaking.ts b/js/hang/src/publish/audio/speaking.ts index b837cce80..a59a3c0b5 100644 --- a/js/hang/src/publish/audio/speaking.ts +++ b/js/hang/src/publish/audio/speaking.ts @@ -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 = { @@ -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", { diff --git a/js/hang/src/publish/video/detection.ts b/js/hang/src/publish/video/detection.ts index e313ef1e4..3ed2da77d 100644 --- a/js/hang/src/publish/video/detection.ts +++ b/js/hang/src/publish/video/detection.ts @@ -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; @@ -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(worker); diff --git a/js/hang/src/support/element.ts b/js/hang/src/support/element.ts index 4db683ce8..48acd56c2 100644 --- a/js/hang/src/support/element.ts +++ b/js/hang/src/support/element.ts @@ -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)); diff --git a/js/hang/src/util/hacks.ts b/js/hang/src/util/hacks.ts index d6f2a8e57..cbfdb9eda 100644 --- a/js/hang/src/util/hacks.ts +++ b/js/hang/src/util/hacks.ts @@ -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"); + +// 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) { + 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; + } + }); +} diff --git a/js/hang/src/watch/audio/index.ts b/js/hang/src/watch/audio/index.ts index 05df44a15..796c14c86 100644 --- a/js/hang/src/watch/audio/index.ts +++ b/js/hang/src/watch/audio/index.ts @@ -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; @@ -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 { @@ -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)), + ), + ); // Create the worklet node const worklet = new AudioWorkletNode(context, "render"); diff --git a/js/package.json b/js/package.json index f9f5fe30b..b0e722c9f 100644 --- a/js/package.json +++ b/js/package.json @@ -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": "pnpm@10.12.1" } diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 00518ed10..b8feeda00 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: knip: specifier: ^5.60.2 version: 5.60.2(@types/node@24.0.1)(typescript@5.8.3) + prettier: + specifier: ^3.6.2 + version: 3.6.2 rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -48,6 +51,9 @@ importers: '@kixelated/signals': specifier: workspace:^ version: link:../signals + async-mutex: + specifier: ^0.5.0 + version: 0.5.0 comlink: specifier: ^4.4.2 version: 4.4.2 @@ -1539,6 +1545,11 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + protobufjs@7.5.3: resolution: {integrity: sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==} engines: {node: '>=12.0.0'} @@ -3012,6 +3023,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prettier@3.6.2: {} + protobufjs@7.5.3: dependencies: '@protobufjs/aspromise': 1.1.2