Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
49 changes: 49 additions & 0 deletions js/publish/src/audio/encoder.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}): Record<string, unknown> | undefined {
return toEncoderConfig(OPUS, kind, opusOptions).opus as Record<string, unknown> | 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);
});
});
28 changes: 21 additions & 7 deletions js/publish/src/audio/encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,10 +393,27 @@ interface OpusEncoderConfigExt extends OpusEncoderConfig {
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).
function toEncoderConfig(
// Exported only for the in-package test; not re-exported from ./index, so not public API.
export function toEncoderConfig(
config: Catalog.AudioConfig,
kind: Kind,
opusOptions: OpusEncoderConfigExt,
Expand All @@ -415,12 +432,9 @@ function toEncoderConfig(
}

if (config.codec === "opus") {
const opus: OpusEncoderConfigExt = { ...opusOptions };

if (kind !== "auto") {
opus.application = kind === "voice" ? "voip" : "audio";
opus.signal = kind;
}
// 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) {
Expand Down
12 changes: 11 additions & 1 deletion js/publish/src/audio/index.ts
Original file line number Diff line number Diff line change
@@ -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";
5 changes: 3 additions & 2 deletions js/publish/src/audio/types.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Loading