From 156bccccc2b777104a3c9b15697deaad6d176d71 Mon Sep 17 00:00:00 2001 From: nuts_rice Date: Mon, 1 Jun 2026 13:46:12 -0600 Subject: [PATCH 1/7] test(opus_dtx): add opus dtx playback test wip --- js/publish/src/audio/capture-playback.test.ts | 63 +++++++++++++++++++ js/publish/src/audio/encoder-config.ts | 34 ++++++++++ js/publish/src/audio/encoder.ts | 29 +-------- 3 files changed, 99 insertions(+), 27 deletions(-) create mode 100644 js/publish/src/audio/capture-playback.test.ts create mode 100644 js/publish/src/audio/encoder-config.ts diff --git a/js/publish/src/audio/capture-playback.test.ts b/js/publish/src/audio/capture-playback.test.ts new file mode 100644 index 000000000..0280d6169 --- /dev/null +++ b/js/publish/src/audio/capture-playback.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, mock, test } from "bun:test"; + +// The ?worklet suffix is a Vite plugin transform; stub it so bun can import encoder.ts. +mock.module("./capture-worklet.ts?worklet", () => ({ default: "" })); + +import * as Catalog from "@moq/hang/catalog"; +import { toEncoderConfig } from "./encoder"; + +const BASE_CONFIG: Catalog.AudioConfig = { + codec: "opus", + sampleRate: Catalog.u53(48_000), + numberOfChannels: Catalog.u53(2), + bitrate: Catalog.u53(64_000), + container: { kind: "legacy" }, +}; + +describe("toEncoderConfig opus flags", () => { + test("voice sets voip application, voice signal, and enables DTX", () => { + const { opus } = toEncoderConfig(BASE_CONFIG, "voice") as { opus: Record }; + expect(opus?.application).toBe("voip"); + expect(opus?.signal).toBe("voice"); + expect(opus?.usedtx).toBe(true); + }); + + test("music sets audio application, music signal, and leaves DTX off", () => { + const { opus } = toEncoderConfig(BASE_CONFIG, "music") as { opus: Record }; + expect(opus?.application).toBe("audio"); + expect(opus?.signal).toBe("music"); + expect(opus?.usedtx).toBeUndefined(); + }); + + test("auto produces no opus-specific config", () => { + const config = toEncoderConfig(BASE_CONFIG, "auto"); + expect(config.opus).toBeUndefined(); + }); +}); + +// 20 ms of silence at 48 kHz stereo (960 samples × 2 channels interleaved = 1920 floats). +describe("silence intervals", () => { + function silenceFrame(sampleRate = 48_000, durationMs = 20, channels = 2): Float32Array { + return new Float32Array((sampleRate * durationMs * channels) / 1_000); + } + + test("silence frame contains only zeros", () => { + const frame = silenceFrame(); + expect(frame.every((s) => s === 0)).toBe(true); + }); + + test("voice config enables DTX , silence padding between ", () => { + const config = toEncoderConfig(BASE_CONFIG, "voice"); + const opus = config.opus as Record | undefined; + expect(opus?.usedtx).toBe(true); + + const frame = silenceFrame(); + expect(frame.length).toBe(1_920); + }); + + test("music config leaves DTX off ,silence is encoded as a full frame", () => { + const config = toEncoderConfig(BASE_CONFIG, "music"); + const opus = config.opus as Record | undefined; + expect(opus?.usedtx).toBeUndefined(); + }); +}); diff --git a/js/publish/src/audio/encoder-config.ts b/js/publish/src/audio/encoder-config.ts new file mode 100644 index 000000000..7d68d94ff --- /dev/null +++ b/js/publish/src/audio/encoder-config.ts @@ -0,0 +1,34 @@ +import type * as Catalog from "@moq/hang/catalog"; +import type { Kind } from "./types"; + +// `application`, `signal`, and `usedtx` are in the WebCodecs spec but missing from lib.dom.d.ts. +// https://www.w3.org/TR/webcodecs-opus-codec-registration/#dom-opusencoderconfig +interface OpusEncoderConfigExt extends OpusEncoderConfig { + application?: "voip" | "audio" | "lowdelay"; + signal?: "auto" | "voice" | "music"; + usedtx?: boolean; +} + +// Build the WebCodecs encoder config from the catalog (decoder) config plus a Kind hint. +// Opus-only knobs are kept out of the catalog since they only affect encoding. +// DTX is enabled for voice: speech has natural silence gaps where DTX emits tiny comfort-noise +// packets instead of full frames. Music has no useful silence to suppress. +export function toEncoderConfig(config: Catalog.AudioConfig, kind: Kind): AudioEncoderConfig { + const encoderConfig: AudioEncoderConfig = { + codec: config.codec, + sampleRate: config.sampleRate, + numberOfChannels: config.numberOfChannels, + bitrate: config.bitrate, + }; + + if (config.codec === "opus" && kind !== "auto") { + const opus: OpusEncoderConfigExt = { + application: kind === "voice" ? "voip" : "audio", + signal: kind, + usedtx: kind === "voice", + }; + encoderConfig.opus = opus; + } + + return encoderConfig; +} diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 3d90f9d72..76cacdf72 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -5,6 +5,7 @@ import type * as Moq from "@moq/net"; import type { Time } from "@moq/net"; import { Effect, type Getter, Signal } from "@moq/signals"; import type * as Capture from "./capture"; +import { toEncoderConfig } from "./encoder-config"; import { type Kind, normalizeSource, type Source } from "./types"; const GAIN_MIN = 0.001; @@ -260,30 +261,4 @@ export class Encoder { } } -// `application` and `signal` are in the WebCodecs spec but missing from lib.dom.d.ts. -// https://www.w3.org/TR/webcodecs-opus-codec-registration/#dom-opusencoderconfig -interface OpusEncoderConfigExt extends OpusEncoderConfig { - application?: "voip" | "audio" | "lowdelay"; - signal?: "auto" | "voice" | "music"; -} - -// Build the WebCodecs encoder config from the catalog (decoder) config plus a Kind hint. -// Opus-only knobs are kept out of the catalog since they only affect encoding. -function toEncoderConfig(config: Catalog.AudioConfig, kind: Kind): AudioEncoderConfig { - const encoderConfig: AudioEncoderConfig = { - codec: config.codec, - sampleRate: config.sampleRate, - numberOfChannels: config.numberOfChannels, - bitrate: config.bitrate, - }; - - if (config.codec === "opus" && kind !== "auto") { - const opus: OpusEncoderConfigExt = { - application: kind === "voice" ? "voip" : "audio", - signal: kind, - }; - encoderConfig.opus = opus; - } - - return encoderConfig; -} +export { toEncoderConfig } from "./encoder-config"; From c7793a40a948d982b50af037c439b37db9a8db97 Mon Sep 17 00:00:00 2001 From: nuts_rice Date: Mon, 1 Jun 2026 13:52:06 -0600 Subject: [PATCH 2/7] fix(opus_dtx): fix usedtx condition --- js/publish/src/audio/encoder-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/publish/src/audio/encoder-config.ts b/js/publish/src/audio/encoder-config.ts index 7d68d94ff..ff859c96c 100644 --- a/js/publish/src/audio/encoder-config.ts +++ b/js/publish/src/audio/encoder-config.ts @@ -25,7 +25,7 @@ export function toEncoderConfig(config: Catalog.AudioConfig, kind: Kind): AudioE const opus: OpusEncoderConfigExt = { application: kind === "voice" ? "voip" : "audio", signal: kind, - usedtx: kind === "voice", + ...(kind === "voice" && { usedtx: true }), }; encoderConfig.opus = opus; } From 4bb9295ab7a2480d5c25ae7ffd1eb0f63f6e31f1 Mon Sep 17 00:00:00 2001 From: nuts_rice Date: Mon, 1 Jun 2026 14:01:19 -0600 Subject: [PATCH 3/7] fix: add worklet import --- js/publish/src/audio/encoder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 76cacdf72..73a244814 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -13,8 +13,6 @@ const FADE_TIME = 0.2; const OPUS_BITRATE_PER_CHANNEL = 32_000; const OPUS_FRAME_DURATION = 20; -// Compiled and inlined as a blob URL via vite-plugin-worklet. -import CaptureWorklet from "./capture-worklet.ts?worklet"; // The initial values for our signals. export type EncoderProps = { @@ -96,6 +94,8 @@ export class Encoder { // Async because we need to wait for the worklet to be registered. effect.spawn(async () => { + // Compiled and inlined as a blob URL via vite-plugin-worklet. + const { default: CaptureWorklet } = await import("./capture-worklet.ts?worklet"); await context.audioWorklet.addModule(CaptureWorklet); if (context.state === "closed") return; From a163975f88662ad5613dd2fab9c43d7a6d167a2f Mon Sep 17 00:00:00 2001 From: nuts_rice Date: Mon, 1 Jun 2026 14:19:00 -0600 Subject: [PATCH 4/7] ci: fix for ci --- js/publish/package.json | 1 + js/publish/src/audio/capture-playback.test.ts | 4 ++-- js/publish/src/audio/encoder.ts | 1 - js/publish/tsconfig.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/js/publish/package.json b/js/publish/package.json index 0ecdcb3b2..6b97e5e8a 100644 --- a/js/publish/package.json +++ b/js/publish/package.json @@ -29,6 +29,7 @@ }, "devDependencies": { "@types/audioworklet": "^0.0.77", + "@types/bun": "^1.3.11", "@typescript/lib-dom": "npm:@types/web@^0.0.241", "esbuild": "^0.27.0", "rimraf": "^6.0.1", diff --git a/js/publish/src/audio/capture-playback.test.ts b/js/publish/src/audio/capture-playback.test.ts index 0280d6169..eb8753b3d 100644 --- a/js/publish/src/audio/capture-playback.test.ts +++ b/js/publish/src/audio/capture-playback.test.ts @@ -16,14 +16,14 @@ const BASE_CONFIG: Catalog.AudioConfig = { describe("toEncoderConfig opus flags", () => { test("voice sets voip application, voice signal, and enables DTX", () => { - const { opus } = toEncoderConfig(BASE_CONFIG, "voice") as { opus: Record }; + const opus = (toEncoderConfig(BASE_CONFIG, "voice") as unknown as { opus: Record }).opus; expect(opus?.application).toBe("voip"); expect(opus?.signal).toBe("voice"); expect(opus?.usedtx).toBe(true); }); test("music sets audio application, music signal, and leaves DTX off", () => { - const { opus } = toEncoderConfig(BASE_CONFIG, "music") as { opus: Record }; + const opus = (toEncoderConfig(BASE_CONFIG, "music") as unknown as { opus: Record }).opus; expect(opus?.application).toBe("audio"); expect(opus?.signal).toBe("music"); expect(opus?.usedtx).toBeUndefined(); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 73a244814..7106808dc 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -13,7 +13,6 @@ const FADE_TIME = 0.2; const OPUS_BITRATE_PER_CHANNEL = 32_000; const OPUS_FRAME_DURATION = 20; - // The initial values for our signals. export type EncoderProps = { enabled?: boolean | Signal; diff --git a/js/publish/tsconfig.json b/js/publish/tsconfig.json index 6041980b7..96547728f 100644 --- a/js/publish/tsconfig.json +++ b/js/publish/tsconfig.json @@ -4,7 +4,7 @@ "rootDir": "./src", "outDir": "dist", "emitDeclarationOnly": true, - "types": ["audioworklet"] + "types": ["audioworklet", "bun"] }, "include": ["src", "../common/worklet.d.ts"] } From df9e58a46ce71e3f125eab88e5e9b6cd70e8bded Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 20:40:05 -0700 Subject: [PATCH 5/7] feat(publish): default Opus DTX from the audio kind Resolve the merge with main and finish the DTX-for-voice work. Main has since landed the usedtx knob and the kind->application/signal plumbing, so the standalone encoder-config.ts extraction is no longer needed: fold all kind-derived Opus settings into opusKindDefaults() inside encoder.ts and add DTX to the voice defaults. Voice now enables discontinuous transmission by default (speech has gaps that DTX collapses to tiny comfort-noise packets); an explicit OpusConfig knob still overrides it. Drops the orphaned encoder-config.ts and moves the tests to encoder.test.ts. Co-Authored-By: Claude Opus 4.8 --- js/publish/src/audio/capture-playback.test.ts | 63 ---------- js/publish/src/audio/encoder-config.ts | 34 ------ js/publish/src/audio/encoder.test.ts | 49 ++++++++ js/publish/src/audio/encoder.ts | 113 +++++++++++++++++- js/publish/src/audio/types.ts | 5 +- 5 files changed, 161 insertions(+), 103 deletions(-) delete mode 100644 js/publish/src/audio/capture-playback.test.ts delete mode 100644 js/publish/src/audio/encoder-config.ts create mode 100644 js/publish/src/audio/encoder.test.ts diff --git a/js/publish/src/audio/capture-playback.test.ts b/js/publish/src/audio/capture-playback.test.ts deleted file mode 100644 index eb8753b3d..000000000 --- a/js/publish/src/audio/capture-playback.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; - -// The ?worklet suffix is a Vite plugin transform; stub it so bun can import encoder.ts. -mock.module("./capture-worklet.ts?worklet", () => ({ default: "" })); - -import * as Catalog from "@moq/hang/catalog"; -import { toEncoderConfig } from "./encoder"; - -const BASE_CONFIG: Catalog.AudioConfig = { - codec: "opus", - sampleRate: Catalog.u53(48_000), - numberOfChannels: Catalog.u53(2), - bitrate: Catalog.u53(64_000), - container: { kind: "legacy" }, -}; - -describe("toEncoderConfig opus flags", () => { - test("voice sets voip application, voice signal, and enables DTX", () => { - const opus = (toEncoderConfig(BASE_CONFIG, "voice") as unknown as { opus: Record }).opus; - expect(opus?.application).toBe("voip"); - expect(opus?.signal).toBe("voice"); - expect(opus?.usedtx).toBe(true); - }); - - test("music sets audio application, music signal, and leaves DTX off", () => { - const opus = (toEncoderConfig(BASE_CONFIG, "music") as unknown as { opus: Record }).opus; - expect(opus?.application).toBe("audio"); - expect(opus?.signal).toBe("music"); - expect(opus?.usedtx).toBeUndefined(); - }); - - test("auto produces no opus-specific config", () => { - const config = toEncoderConfig(BASE_CONFIG, "auto"); - expect(config.opus).toBeUndefined(); - }); -}); - -// 20 ms of silence at 48 kHz stereo (960 samples × 2 channels interleaved = 1920 floats). -describe("silence intervals", () => { - function silenceFrame(sampleRate = 48_000, durationMs = 20, channels = 2): Float32Array { - return new Float32Array((sampleRate * durationMs * channels) / 1_000); - } - - test("silence frame contains only zeros", () => { - const frame = silenceFrame(); - expect(frame.every((s) => s === 0)).toBe(true); - }); - - test("voice config enables DTX , silence padding between ", () => { - const config = toEncoderConfig(BASE_CONFIG, "voice"); - const opus = config.opus as Record | undefined; - expect(opus?.usedtx).toBe(true); - - const frame = silenceFrame(); - expect(frame.length).toBe(1_920); - }); - - test("music config leaves DTX off ,silence is encoded as a full frame", () => { - const config = toEncoderConfig(BASE_CONFIG, "music"); - const opus = config.opus as Record | undefined; - expect(opus?.usedtx).toBeUndefined(); - }); -}); diff --git a/js/publish/src/audio/encoder-config.ts b/js/publish/src/audio/encoder-config.ts deleted file mode 100644 index ff859c96c..000000000 --- a/js/publish/src/audio/encoder-config.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type * as Catalog from "@moq/hang/catalog"; -import type { Kind } from "./types"; - -// `application`, `signal`, and `usedtx` are in the WebCodecs spec but missing from lib.dom.d.ts. -// https://www.w3.org/TR/webcodecs-opus-codec-registration/#dom-opusencoderconfig -interface OpusEncoderConfigExt extends OpusEncoderConfig { - application?: "voip" | "audio" | "lowdelay"; - signal?: "auto" | "voice" | "music"; - usedtx?: boolean; -} - -// Build the WebCodecs encoder config from the catalog (decoder) config plus a Kind hint. -// Opus-only knobs are kept out of the catalog since they only affect encoding. -// DTX is enabled for voice: speech has natural silence gaps where DTX emits tiny comfort-noise -// packets instead of full frames. Music has no useful silence to suppress. -export function toEncoderConfig(config: Catalog.AudioConfig, kind: Kind): AudioEncoderConfig { - const encoderConfig: AudioEncoderConfig = { - codec: config.codec, - sampleRate: config.sampleRate, - numberOfChannels: config.numberOfChannels, - bitrate: config.bitrate, - }; - - if (config.codec === "opus" && kind !== "auto") { - const opus: OpusEncoderConfigExt = { - application: kind === "voice" ? "voip" : "audio", - signal: kind, - ...(kind === "voice" && { usedtx: true }), - }; - encoderConfig.opus = opus; - } - - return encoderConfig; -} diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts new file mode 100644 index 000000000..91cc83689 --- /dev/null +++ b/js/publish/src/audio/encoder.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, mock, test } from "bun:test"; +import * as Catalog from "@moq/hang/catalog"; +import type { Kind } from "./types.ts"; + +// The ?worklet suffix is a Vite plugin transform; stub it so bun can import encoder.ts. The dynamic +// import below has to run after this, since a static import of encoder.ts would be hoisted above it. +mock.module("./capture-worklet.ts?worklet", () => ({ default: "" })); +const { toEncoderConfig } = await import("./encoder.ts"); + +const OPUS: Catalog.AudioConfig = { + codec: "opus", + container: { kind: "legacy" }, + sampleRate: Catalog.u53(48_000), + numberOfChannels: Catalog.u53(2), + bitrate: Catalog.u53(64_000), +}; + +// The Opus-only knobs the encoder eventually hands to WebCodecs. +function opus(kind: Kind, opusOptions: Record = {}): Record | undefined { + return toEncoderConfig(OPUS, kind, opusOptions).opus as Record | undefined; +} + +describe("toEncoderConfig opus kind defaults", () => { + test("voice enables DTX with voip/voice tuning", () => { + const o = opus("voice"); + expect(o?.application).toBe("voip"); + expect(o?.signal).toBe("voice"); + expect(o?.usedtx).toBe(true); + }); + + test("music uses audio/music tuning and leaves DTX to the browser", () => { + const o = opus("music"); + expect(o?.application).toBe("audio"); + expect(o?.signal).toBe("music"); + expect(o?.usedtx).toBeUndefined(); + }); + + test("auto sets no kind-derived opus knobs", () => { + const o = opus("auto"); + expect(o?.application).toBeUndefined(); + expect(o?.signal).toBeUndefined(); + expect(o?.usedtx).toBeUndefined(); + }); + + test("an explicit usedtx overrides the kind default", () => { + expect(opus("voice", { usedtx: false })?.usedtx).toBe(false); + expect(opus("music", { usedtx: true })?.usedtx).toBe(true); + }); +}); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 3fe0c4125..3578b1754 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -5,7 +5,6 @@ import type * as Moq from "@moq/net"; import type { Time } from "@moq/net"; import { Effect, type Getter, Signal } from "@moq/signals"; import type * as Capture from "./capture"; -import { toEncoderConfig } from "./encoder-config"; import { type Kind, normalizeSource, type Source } from "./types"; const GAIN_MIN = 0.001; @@ -18,6 +17,38 @@ const AAC_FRAME_SAMPLES = 1024; // AAC-LC encodes a fixed 1024 samples per frame // The WebCodecs/MP4 codec string for AAC-LC. "aac" is our user-facing shorthand. const AAC_CODEC = "mp4a.40.2"; +// Compiled and inlined as a blob URL via vite-plugin-worklet. +import CaptureWorklet from "./capture-worklet.ts?worklet"; + +// Selects the audio codec and its encoder settings. Either the bare codec name (all defaults) or an +// object with the mime plus tuning knobs. +export type Codec = Opus | Aac; + +export type Opus = "opus" | OpusConfig; +export type Aac = "aac" | AacConfig; + +// AAC encoder settings. AAC-LC has a fixed 1024-sample frame and no real-time tuning knobs, so +// bitrate is the only thing to configure. +export type AacConfig = { + mime: "aac"; + + bitrate?: number; // bits/sec, defaults to channelCount * 64kbps +}; + +// Opus encoder settings. bitrate and frameDuration also shape the catalog (decoders need them); the +// rest are encode-only knobs that map directly to the matching OpusEncoderConfig fields: +// https://developer.mozilla.org/en-US/docs/Web/API/AudioEncoder/configure#opus +export type OpusConfig = { + mime: "opus"; + + bitrate?: number; // bits/sec, defaults to channelCount * 32kbps + frameDuration?: number; // ms, Opus supports 2.5-60ms, defaults to 20ms (the real-time default) + complexity?: number; // 0-10, higher is better quality but more CPU + packetlossperc?: number; // 0-100, expected loss the encoder optimizes for + useinbandfec?: boolean; // in-band forward error correction + usedtx?: boolean; // discontinuous transmission (silence suppression) +}; + // The initial values for our signals. export type EncoderProps = { enabled?: boolean | Signal; @@ -121,8 +152,6 @@ export class Encoder { // Async because we need to wait for the worklet to be registered. effect.spawn(async () => { - // Compiled and inlined as a blob URL via vite-plugin-worklet. - const { default: CaptureWorklet } = await import("./capture-worklet.ts?worklet"); await context.audioWorklet.addModule(CaptureWorklet); if (context.state === "closed") return; @@ -341,4 +370,80 @@ export class Encoder { } } -export { toEncoderConfig } from "./encoder-config"; +// getConstraints() echoes the constraints applied via getUserMedia, which (unlike getSettings) +// survives the macOS mono->stereo misreport. Returns the requested channel count, if any. +function requestedChannelCount(track: MediaStreamTrack): number | undefined { + const constraint = track.getConstraints().channelCount; + if (constraint === undefined) return undefined; + if (typeof constraint === "number") return constraint; + return constraint.exact ?? constraint.ideal ?? constraint.max ?? constraint.min; +} + +// Resolve the bare codec shorthands to their full config object so callers can read fields uniformly. +function normalizeCodec(codec: Codec): OpusConfig | AacConfig { + if (codec === "opus") return { mime: "opus" }; + if (codec === "aac") return { mime: "aac" }; + return codec; +} + +// `application` and `signal` are in the WebCodecs spec but missing from lib.dom.d.ts. +// https://www.w3.org/TR/webcodecs-opus-codec-registration/#dom-opusencoderconfig +interface OpusEncoderConfigExt extends OpusEncoderConfig { + application?: "voip" | "audio" | "lowdelay"; + signal?: "auto" | "voice" | "music"; +} + +// Opus settings implied by the audio kind. These are only defaults: any field set explicitly via +// OpusConfig (carried in opusOptions) overrides them, so a caller can always opt out. DTX (silence +// suppression) is enabled for voice, where speech has natural gaps that collapse to tiny +// comfort-noise packets. Music has no useful silence to suppress, and "auto" leaves every knob to +// the browser. +function opusKindDefaults(kind: Kind): OpusEncoderConfigExt { + switch (kind) { + case "voice": + return { application: "voip", signal: "voice", usedtx: true }; + case "music": + return { application: "audio", signal: "music" }; + default: + return {}; + } +} + +// Build the WebCodecs encoder config from the catalog (decoder) config, a Kind hint, and any +// Opus-only knobs. Those knobs are kept out of the catalog since they only affect encoding. AAC has +// no such knobs, so it just uses the shared base fields (codec/sampleRate/channels/bitrate). +export function toEncoderConfig( + config: Catalog.AudioConfig, + kind: Kind, + opusOptions: OpusEncoderConfigExt, +): AudioEncoderConfig { + const encoderConfig: AudioEncoderConfig = { + codec: config.codec, + sampleRate: config.sampleRate, + numberOfChannels: config.numberOfChannels, + bitrate: config.bitrate, + }; + + if (config.codec.startsWith("mp4a")) { + // Pin raw AAC: the catalog carries a synthesized AudioSpecificConfig, which is only valid for + // raw frames. An ADTS default would make the frames self-describing and that description wrong. + encoderConfig.aac = { format: "aac" }; + } + + if (config.codec === "opus") { + // Start from the kind's defaults, then let explicit opusOptions win (undefined knobs were + // already dropped upstream, so the spread only overrides what the caller actually set). + const opus: OpusEncoderConfigExt = { ...opusKindDefaults(kind), ...opusOptions }; + + // jitter carries the frame duration in ms; WebCodecs wants µs. + if (config.jitter !== undefined) { + opus.frameDuration = config.jitter * 1000; + } + + if (Object.keys(opus).length > 0) { + encoderConfig.opus = opus; + } + } + + return encoderConfig; +} diff --git a/js/publish/src/audio/types.ts b/js/publish/src/audio/types.ts index 0265e8c17..4bb5c542b 100644 --- a/js/publish/src/audio/types.ts +++ b/js/publish/src/audio/types.ts @@ -1,5 +1,6 @@ -// The kind of audio being encoded. Drives Opus application/signal settings on the encoder. -// - "voice": speech (microphone). Opus application=voip + signal=voice. +// The kind of audio being encoded. Drives the default Opus application/signal/DTX settings on the +// encoder, each of which an explicit OpusConfig can still override. +// - "voice": speech (microphone). Opus application=voip, signal=voice, and DTX on (speech has gaps). // - "music": music or mixed content (screen/tab capture). Opus application=audio + signal=music. // - "auto": let the encoder decide. Opus defaults (good for unknown sources like file playback). export type Kind = "voice" | "music" | "auto"; From e20fe685c70561809b7ca51a27ea1290c1019297 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 20:43:03 -0700 Subject: [PATCH 6/7] fix(publish): keep toEncoderConfig out of the public API The previous commit exported toEncoderConfig so the in-package test could import it, but audio/index.ts re-exports the encoder module with `export *`, which leaked it into @moq/publish's public surface as Audio.toEncoderConfig. Switch that barrel to a named re-export so the published API is unchanged; the symbol stays importable within the package for the test. Co-Authored-By: Claude Opus 4.8 --- js/publish/src/audio/encoder.ts | 1 + js/publish/src/audio/index.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 3578b1754..766afbc6a 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -412,6 +412,7 @@ function opusKindDefaults(kind: Kind): OpusEncoderConfigExt { // Build the WebCodecs encoder config from the catalog (decoder) config, a Kind hint, and any // Opus-only knobs. Those knobs are kept out of the catalog since they only affect encoding. AAC has // no such knobs, so it just uses the shared base fields (codec/sampleRate/channels/bitrate). +// Exported only for the in-package test; not re-exported from ./index, so not public API. export function toEncoderConfig( config: Catalog.AudioConfig, kind: Kind, diff --git a/js/publish/src/audio/index.ts b/js/publish/src/audio/index.ts index 99637cfdc..defc1a8d8 100644 --- a/js/publish/src/audio/index.ts +++ b/js/publish/src/audio/index.ts @@ -1,2 +1,12 @@ -export * from "./encoder"; +// toEncoderConfig is intentionally omitted: it's exported from ./encoder only so the in-package test +// can import it, not as part of the public API. +export { + type Aac, + type AacConfig, + type Codec, + Encoder, + type EncoderProps, + type Opus, + type OpusConfig, +} from "./encoder"; export * from "./types"; From 375e83e86905f56647e43c85f3b746a018fab28f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 21:32:34 -0700 Subject: [PATCH 7/7] test(publish): drop the toEncoderConfig unit test The test only restated the opusKindDefaults table, so it added little beyond what the code already says. Removing it lets toEncoderConfig go back to private and restores the plain `export *` barrel, leaving the public API untouched by this change. Co-Authored-By: Claude Opus 4.8 --- js/publish/src/audio/encoder.test.ts | 49 ---------------------------- js/publish/src/audio/encoder.ts | 3 +- js/publish/src/audio/index.ts | 12 +------ 3 files changed, 2 insertions(+), 62 deletions(-) delete mode 100644 js/publish/src/audio/encoder.test.ts diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts deleted file mode 100644 index 91cc83689..000000000 --- a/js/publish/src/audio/encoder.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; -import * as Catalog from "@moq/hang/catalog"; -import type { Kind } from "./types.ts"; - -// The ?worklet suffix is a Vite plugin transform; stub it so bun can import encoder.ts. The dynamic -// import below has to run after this, since a static import of encoder.ts would be hoisted above it. -mock.module("./capture-worklet.ts?worklet", () => ({ default: "" })); -const { toEncoderConfig } = await import("./encoder.ts"); - -const OPUS: Catalog.AudioConfig = { - codec: "opus", - container: { kind: "legacy" }, - sampleRate: Catalog.u53(48_000), - numberOfChannels: Catalog.u53(2), - bitrate: Catalog.u53(64_000), -}; - -// The Opus-only knobs the encoder eventually hands to WebCodecs. -function opus(kind: Kind, opusOptions: Record = {}): Record | undefined { - return toEncoderConfig(OPUS, kind, opusOptions).opus as Record | undefined; -} - -describe("toEncoderConfig opus kind defaults", () => { - test("voice enables DTX with voip/voice tuning", () => { - const o = opus("voice"); - expect(o?.application).toBe("voip"); - expect(o?.signal).toBe("voice"); - expect(o?.usedtx).toBe(true); - }); - - test("music uses audio/music tuning and leaves DTX to the browser", () => { - const o = opus("music"); - expect(o?.application).toBe("audio"); - expect(o?.signal).toBe("music"); - expect(o?.usedtx).toBeUndefined(); - }); - - test("auto sets no kind-derived opus knobs", () => { - const o = opus("auto"); - expect(o?.application).toBeUndefined(); - expect(o?.signal).toBeUndefined(); - expect(o?.usedtx).toBeUndefined(); - }); - - test("an explicit usedtx overrides the kind default", () => { - expect(opus("voice", { usedtx: false })?.usedtx).toBe(false); - expect(opus("music", { usedtx: true })?.usedtx).toBe(true); - }); -}); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 766afbc6a..06ff78663 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -412,8 +412,7 @@ function opusKindDefaults(kind: Kind): OpusEncoderConfigExt { // Build the WebCodecs encoder config from the catalog (decoder) config, a Kind hint, and any // Opus-only knobs. Those knobs are kept out of the catalog since they only affect encoding. AAC has // no such knobs, so it just uses the shared base fields (codec/sampleRate/channels/bitrate). -// Exported only for the in-package test; not re-exported from ./index, so not public API. -export function toEncoderConfig( +function toEncoderConfig( config: Catalog.AudioConfig, kind: Kind, opusOptions: OpusEncoderConfigExt, diff --git a/js/publish/src/audio/index.ts b/js/publish/src/audio/index.ts index defc1a8d8..99637cfdc 100644 --- a/js/publish/src/audio/index.ts +++ b/js/publish/src/audio/index.ts @@ -1,12 +1,2 @@ -// toEncoderConfig is intentionally omitted: it's exported from ./encoder only so the in-package test -// can import it, not as part of the public API. -export { - type Aac, - type AacConfig, - type Codec, - Encoder, - type EncoderProps, - type Opus, - type OpusConfig, -} from "./encoder"; +export * from "./encoder"; export * from "./types";