Skip to content
Open
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
37 changes: 23 additions & 14 deletions src/core/codec-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,32 @@ export function getBrowserSupportedCodecs(): Set<Codec> {
return new Set(["pcm", "flac"] as Codec[]);
}

/** Build supported format list from requested codecs, filtering by browser support. */
/**
* Build the client/hello format list from the requested codecs, in priority
* order and filtered by browser support. Always contains a flac or pcm entry.
*/
export function getSupportedFormats(codecs: Codec[]): SupportedFormat[] {
const browserSupported = getBrowserSupportedCodecs();
const formats: SupportedFormat[] = [];

for (const codec of codecs) {
if (!browserSupported.has(codec)) {
continue;
}
const selected = codecs.filter((codec) => browserSupported.has(codec));

// Servers only have to support flac and pcm, so the protocol requires at
// least one of them in the list. When neither was requested or survived the
// filter, append both (where the browser decodes them) at lowest priority,
// flac first for its lower bandwidth.
if (!selected.includes("flac") && !selected.includes("pcm")) {
const fallback = (["flac", "pcm"] as Codec[]).filter((codec) =>
browserSupported.has(codec),
);
console.warn(
`[Codec] No flac or pcm in usable codecs [${selected.join(", ")}] ` +
`(requested [${codecs.join(", ")}]), advertising ` +
`[${fallback.join(", ")}] as fallback`,
);
selected.push(...fallback);
}

const formats: SupportedFormat[] = [];
for (const codec of selected) {
if (codec === "opus") {
// Opus requires 48kHz
formats.push({
Expand All @@ -81,13 +97,6 @@ export function getSupportedFormats(codecs: Codec[]): SupportedFormat[] {
}
}

if (formats.length === 0) {
throw new Error(
`No supported codecs: requested [${codecs.join(", ")}], ` +
`browser supports [${[...browserSupported].join(", ")}]`,
);
}

return formats;
}

Expand Down
4 changes: 0 additions & 4 deletions src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { SendspinTransport } from "./transport";
import type { HandshakeInfo } from "./transport";
import { SUITES } from "./noise/suites";
import { PairingManager } from "./pairing";
import { getSupportedFormats } from "./codec-support";
import { base64urlEncode, base64urlDecode } from "./noise/base64url";
import { encodePairingToken } from "./noise/pairing-token";
import type {
Expand Down Expand Up @@ -69,9 +68,6 @@ export class SendspinCore implements StreamHandler {
private _onConnectionClose?: () => void;

constructor(config: SendspinCoreConfig) {
// Validate configured codecs up front so a set with no browser overlap
// throws to the app instead of failing silently inside client/hello dispatch.
if (config.codecs) getSupportedFormats(config.codecs);
this.hasStorage = (config.storage ?? null) !== null;
this.identity = Identity.loadOrCreate(config.storage ?? null);
const clientName =
Expand Down
22 changes: 19 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,19 @@ export interface ClientCommand {

export interface ServerHello {
type: MessageType.SERVER_HELLO;
payload: Record<string, unknown>;
payload: {
name: string;
languages?: string[];
/**
* Present only when the server supports the source@v1 role, which this SDK
* does not implement. Lists the codecs the server accepts in
* client-stream/start; always includes flac and pcm, and may include
* identifiers unknown to this SDK.
*/
"source@v1_support"?: {
supported_codecs: string[];
};
};
}

export interface ServerTime {
Expand Down Expand Up @@ -571,6 +583,10 @@ export interface SendspinCoreConfig {
* - Browsers with WebCodecs (Chrome, Edge): All codecs
* - Browsers without WebCodecs (e.g., insecure context or older browsers): No Opus
*
* Servers are only required to support FLAC and PCM, so when neither "flac"
* nor "pcm" remains, both are appended (where the browser decodes them) as
* lowest-priority fallbacks, "flac" first.
*
* Default: ["opus", "flac", "pcm"]
*/
codecs?: Codec[];
Expand All @@ -580,8 +596,8 @@ export interface SendspinCoreConfig {
* not-yet-played encoded audio it may send ahead.
*
* Defaults to the server's stream-ahead depth at the worst-case byte rate of
* the negotiable formats: ~5.9MB when FLAC or PCM is offered, ~1.9MB for
* Opus-only. Set this only for clients with a real, smaller buffer.
* the advertised formats (~5.8-5.9MB, as FLAC or PCM is always offered). Set
* this only for clients with a real, smaller buffer.
*/
bufferCapacity?: number;

Expand Down
96 changes: 83 additions & 13 deletions tests/unit/codec-support.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
*
* Covers browser codec detection (Safari / Firefox / Chrome, with and without
* WebCodecs AudioDecoder), format expansion rules, browser filtering of
* requested codecs, and the "no supported codecs" throw.
* requested codecs, and the flac/pcm entry the protocol requires.
*
* navigator / AudioDecoder / window don't exist in node; each test installs
* the minimal globals it needs and restores them afterward.
*/

import { describe, it, expect, afterEach, vi } from "vitest";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import {
getBrowserSupportedCodecs,
getDefaultBufferCapacity,
Expand Down Expand Up @@ -113,10 +113,11 @@ describe("getSupportedFormats", () => {

it("expands opus to a single 48kHz format only", () => {
setEnv({ userAgent: UA.chrome, hasAudioDecoder: true });
const formats = getSupportedFormats(["opus"]);
expect(formats.length).toBe(1);
expect(formats[0].codec).toBe("opus");
expect(formats[0].sample_rate).toBe(48000);
const opus = getSupportedFormats(["opus"]).filter(
(f) => f.codec === "opus",
);
expect(opus.length).toBe(1);
expect(opus[0].sample_rate).toBe(48000);
});

it("expands pcm to both 48kHz and 44.1kHz", () => {
Expand Down Expand Up @@ -145,10 +146,69 @@ describe("getSupportedFormats", () => {
expect(formats.some((f) => f.codec === "opus")).toBe(false);
});

it("throws when every requested codec is unsupported", () => {
// Firefox does not support opus; requesting only opus leaves nothing.
setEnv({ userAgent: UA.firefox, hasAudioDecoder: true });
expect(() => getSupportedFormats(["opus"])).toThrow(/No supported codecs/);
// Servers only have to support flac and pcm, so a player must offer at
// least one of them; the fallback is flac then pcm, as the browser allows.
describe("flac/pcm guarantee", () => {
beforeEach(() => {
vi.spyOn(console, "warn").mockImplementation(() => {});
});

it("appends flac then pcm at lowest priority when only opus is requested", () => {
setEnv({ userAgent: UA.chrome, hasAudioDecoder: true });
const formats = getSupportedFormats(["opus"]);
expect(formats.map((f) => f.codec)).toEqual([
"opus",
"flac",
"flac",
"pcm",
"pcm",
]);
expect(console.warn).toHaveBeenCalledOnce();
});

it("advertises flac and pcm for an empty codec list", () => {
setEnv({ userAgent: UA.chrome, hasAudioDecoder: true });
const formats = getSupportedFormats([]);
expect(formats.map((f) => f.codec)).toEqual([
"flac",
"flac",
"pcm",
"pcm",
]);
});

it("appends only pcm when the browser cannot decode flac", () => {
// Safari has no FLAC, so opus+flac would otherwise leave opus alone.
setEnv({ userAgent: UA.safari, hasAudioDecoder: true });
const formats = getSupportedFormats(["opus", "flac"]);
expect(formats.map((f) => f.codec)).toEqual(["opus", "pcm", "pcm"]);
});

it("falls back to flac and pcm when every requested codec is unsupported", () => {
// Firefox does not support opus; requesting only opus leaves nothing.
setEnv({ userAgent: UA.firefox, hasAudioDecoder: true });
const formats = getSupportedFormats(["opus"]);
expect(formats.map((f) => f.codec)).toEqual([
"flac",
"flac",
"pcm",
"pcm",
]);
});

it("appends nothing when flac is already offered", () => {
setEnv({ userAgent: UA.chrome, hasAudioDecoder: true });
const formats = getSupportedFormats(["opus", "flac"]);
expect(formats.some((f) => f.codec === "pcm")).toBe(false);
expect(console.warn).not.toHaveBeenCalled();
});

it("appends nothing when pcm is already offered", () => {
setEnv({ userAgent: UA.chrome, hasAudioDecoder: true });
const formats = getSupportedFormats(["opus", "pcm"]);
expect(formats.some((f) => f.codec === "flac")).toBe(false);
expect(console.warn).not.toHaveBeenCalled();
});
});
});

Expand All @@ -172,19 +232,29 @@ describe("getDefaultBufferCapacity", () => {

it("sizes for the highest byte rate among the advertised formats", () => {
setEnv({ userAgent: UA.chrome, hasAudioDecoder: true });
const opusOnly = getDefaultBufferCapacity(getSupportedFormats(["opus"]));
const withPcm = getDefaultBufferCapacity(
getSupportedFormats(["opus", "pcm"]),
);
const withFlac = getDefaultBufferCapacity(
getSupportedFormats(["opus", "flac"]),
);
expect(withFlac).toBeGreaterThan(opusOnly);
expect(withFlac).toBeGreaterThan(withPcm);
expect(withFlac).toBe(
getDefaultBufferCapacity(getSupportedFormats(["flac"])),
);
});

it("stays well above the stream-ahead depth for Opus", () => {
it("covers the flac fallback appended to an opus-only request", () => {
setEnv({ userAgent: UA.chrome, hasAudioDecoder: true });
const capacity = getDefaultBufferCapacity(getSupportedFormats(["opus"]));
expect(capacity).toBeGreaterThanOrEqual(195_400 * DEPTH_SECONDS);
});

it("stays well above the stream-ahead depth for Opus", () => {
// Hand-built list: getSupportedFormats never returns opus on its own.
const capacity = getDefaultBufferCapacity([
{ codec: "opus", sample_rate: 48000, channels: 2, bit_depth: 16 },
]);
// ~96 kbps in practice, so the depth covered is far beyond 30s.
expect(capacity / (96_000 / 8)).toBeGreaterThan(DEPTH_SECONDS);
});
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/protocol-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { StateManager } from "../../src/core/state-manager";
import type { SendspinTimeFilter } from "../../src/core/time-filter";
import type { StreamHandler } from "../../src/internal-types";
import { MessageType, type ServerHello } from "../../src/types";

function makeHelloContext(overrides: Partial<HelloContext> = {}): HelloContext {
return {
Expand Down Expand Up @@ -255,6 +256,26 @@ describe("ProtocolHandler extra", () => {
)["player@v1_support"] as Record<string, unknown>;
expect(support.buffer_capacity).toBe(123_456);
});

it("always offers flac or pcm, even when only opus is requested", () => {
// Servers need not support opus, so the hello must carry a codec every
// server can produce. Stub WebCodecs so opus survives the browser filter.
(globalThis as any).AudioDecoder = class {};
try {
makeHandler({ codecs: ["opus"] }).sendClientHello();
} finally {
delete (globalThis as any).AudioDecoder;
}

const support = (
lastSent(send, "client/hello")!.payload as Record<string, unknown>
)["player@v1_support"] as Record<string, unknown>;
const codecs = (
support.supported_formats as Array<Record<string, unknown>>
).map((f) => f.codec);
expect(codecs[0]).toBe("opus");
expect(codecs).toContain("pcm");
});
});

describe("handleServerHello", () => {
Expand Down Expand Up @@ -288,6 +309,19 @@ describe("ProtocolHandler extra", () => {
dispatch(handler, { type: "server/hello", payload: {} });
expect(lastSent(send, "client/state")).toBeUndefined();
});

it("still replies to a server/hello carrying source@v1_support", () => {
const handler = makeHandler();
const hello: ServerHello = {
type: MessageType.SERVER_HELLO,
payload: {
name: "Srv",
"source@v1_support": { supported_codecs: ["opus", "flac", "pcm"] },
},
};
handler.handleServerMessage(hello);
expect(lastSent(send, "client/hello")).toBeDefined();
});
});

describe("handleServerActivate", () => {
Expand Down