diff --git a/src/core/codec-support.ts b/src/core/codec-support.ts index 8c659d4..e9eb263 100644 --- a/src/core/codec-support.ts +++ b/src/core/codec-support.ts @@ -56,16 +56,32 @@ export function getBrowserSupportedCodecs(): Set { 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({ @@ -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; } diff --git a/src/core/core.ts b/src/core/core.ts index 91c58f0..a298d04 100644 --- a/src/core/core.ts +++ b/src/core/core.ts @@ -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 { @@ -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 = diff --git a/src/types.ts b/src/types.ts index 91d9288..2329a83 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,7 +144,19 @@ export interface ClientCommand { export interface ServerHello { type: MessageType.SERVER_HELLO; - payload: Record; + 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 { @@ -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[]; @@ -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; diff --git a/tests/unit/codec-support.test.ts b/tests/unit/codec-support.test.ts index c559f78..25b4719 100644 --- a/tests/unit/codec-support.test.ts +++ b/tests/unit/codec-support.test.ts @@ -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, @@ -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", () => { @@ -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(); + }); }); }); @@ -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); }); diff --git a/tests/unit/protocol-handler.test.ts b/tests/unit/protocol-handler.test.ts index 653e258..2c3af19 100644 --- a/tests/unit/protocol-handler.test.ts +++ b/tests/unit/protocol-handler.test.ts @@ -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 { return { @@ -255,6 +256,26 @@ describe("ProtocolHandler extra", () => { )["player@v1_support"] as Record; 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 + )["player@v1_support"] as Record; + const codecs = ( + support.supported_formats as Array> + ).map((f) => f.codec); + expect(codecs[0]).toBe("opus"); + expect(codecs).toContain("pcm"); + }); }); describe("handleServerHello", () => { @@ -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", () => {