branch:
sfu-utils.test.ts
9748 bytesRaw
/**
 * Unit tests for SFU utility functions.
 *
 * Tests protobuf varint encoding/decoding, packet encode/decode roundtrips,
 * and audio format conversion (48kHz stereo ↔ 16kHz mono).
 */
import { describe, expect, it } from "vitest";
import {
  decodeVarint,
  encodeVarint,
  extractPayloadFromProtobuf,
  encodePayloadToProtobuf,
  downsample48kStereoTo16kMono,
  upsample16kMonoTo48kStereo
} from "../sfu-utils";

// --- Varint encoding/decoding ---

describe("varint encoding", () => {
  it("encodes single-byte values (0–127)", () => {
    expect(encodeVarint(0)).toEqual(new Uint8Array([0]));
    expect(encodeVarint(1)).toEqual(new Uint8Array([1]));
    expect(encodeVarint(127)).toEqual(new Uint8Array([127]));
  });

  it("encodes multi-byte values (>127)", () => {
    // 128 = 0x80 → [0x80, 0x01]
    expect(encodeVarint(128)).toEqual(new Uint8Array([0x80, 0x01]));
    // 300 = 0x012C → [0xAC, 0x02]
    expect(encodeVarint(300)).toEqual(new Uint8Array([0xac, 0x02]));
  });

  it("roundtrips through decode", () => {
    for (const value of [0, 1, 42, 127, 128, 255, 300, 16384, 65535]) {
      const encoded = encodeVarint(value);
      const decoded = decodeVarint(encoded, 0);
      expect(decoded.value).toBe(value);
      expect(decoded.bytesRead).toBe(encoded.length);
    }
  });

  it("decodes at arbitrary offsets", () => {
    // Prefix with 3 garbage bytes, then encode 42
    const payload = encodeVarint(42);
    const buf = new Uint8Array(3 + payload.length);
    buf[0] = 0xff;
    buf[1] = 0xff;
    buf[2] = 0xff;
    buf.set(payload, 3);

    const { value, bytesRead } = decodeVarint(buf, 3);
    expect(value).toBe(42);
    expect(bytesRead).toBe(1);
  });
});

// --- Protobuf packet encode/decode ---

describe("protobuf packet encode/decode", () => {
  it("roundtrips payload through encode → decode", () => {
    const original = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
    const encoded = encodePayloadToProtobuf(original);
    const decoded = extractPayloadFromProtobuf(encoded);

    expect(decoded).not.toBeNull();
    expect(new Uint8Array(decoded!)).toEqual(original);
  });

  it("roundtrips empty payload", () => {
    const original = new Uint8Array([]);
    const encoded = encodePayloadToProtobuf(original);
    const decoded = extractPayloadFromProtobuf(encoded);

    expect(decoded).not.toBeNull();
    expect(decoded!.length).toBe(0);
  });

  it("roundtrips large payload (simulating audio frame)", () => {
    // 960 samples at 48kHz stereo = 20ms frame = 3840 bytes
    const original = new Uint8Array(3840);
    for (let i = 0; i < original.length; i++) {
      original[i] = i % 256;
    }
    const encoded = encodePayloadToProtobuf(original);
    const decoded = extractPayloadFromProtobuf(encoded);

    expect(decoded).not.toBeNull();
    expect(decoded!.length).toBe(3840);
    expect(new Uint8Array(decoded!)).toEqual(original);
  });

  it("extracts payload from a packet with other fields", () => {
    // Manually build a protobuf with:
    //   field 1 (sequenceNumber) = 42, varint
    //   field 2 (timestamp) = 1000, varint
    //   field 5 (payload) = [0xAA, 0xBB]
    const parts: Uint8Array[] = [];

    // Field 1, wire type 0: tag = (1 << 3) | 0 = 8
    parts.push(encodeVarint(8));
    parts.push(encodeVarint(42));

    // Field 2, wire type 0: tag = (2 << 3) | 0 = 16
    parts.push(encodeVarint(16));
    parts.push(encodeVarint(1000));

    // Field 5, wire type 2: tag = (5 << 3) | 2 = 42
    parts.push(encodeVarint(42));
    parts.push(encodeVarint(2)); // length = 2
    parts.push(new Uint8Array([0xaa, 0xbb]));

    const totalLen = parts.reduce((s, p) => s + p.length, 0);
    const buf = new Uint8Array(totalLen);
    let offset = 0;
    for (const part of parts) {
      buf.set(part, offset);
      offset += part.length;
    }

    const payload = extractPayloadFromProtobuf(buf.buffer);
    expect(payload).not.toBeNull();
    expect(new Uint8Array(payload!)).toEqual(new Uint8Array([0xaa, 0xbb]));
  });

  it("returns null for empty buffer", () => {
    expect(extractPayloadFromProtobuf(new ArrayBuffer(0))).toBeNull();
  });

  it("returns null when payload field is missing", () => {
    // Only field 1 (sequenceNumber)
    const tag = encodeVarint(8); // field 1, wire type 0
    const val = encodeVarint(42);
    const buf = new Uint8Array(tag.length + val.length);
    buf.set(tag, 0);
    buf.set(val, tag.length);

    expect(extractPayloadFromProtobuf(buf.buffer)).toBeNull();
  });
});

// --- Audio conversion ---

describe("audio conversion", () => {
  /**
   * Helper: create 48kHz stereo PCM buffer with known sample values.
   * Each stereo sample pair is 4 bytes (L int16 LE + R int16 LE).
   * For downsampling, we need groups of 3 stereo pairs → 1 mono sample.
   */
  function make48kStereo(monoSamples: number[]): Uint8Array {
    // Each mono sample becomes 3 stereo pairs (for 3:1 ratio)
    const buf = new ArrayBuffer(monoSamples.length * 3 * 4);
    const view = new DataView(buf);
    for (let i = 0; i < monoSamples.length; i++) {
      const sample = monoSamples[i];
      for (let j = 0; j < 3; j++) {
        const offset = (i * 3 + j) * 4;
        view.setInt16(offset, sample, true); // left
        view.setInt16(offset + 2, sample, true); // right
      }
    }
    return new Uint8Array(buf);
  }

  /** Helper: read 16kHz mono PCM samples from ArrayBuffer. */
  function read16kMono(buf: ArrayBuffer): number[] {
    const view = new DataView(buf);
    const samples: number[] = [];
    for (let i = 0; i < buf.byteLength; i += 2) {
      samples.push(view.getInt16(i, true));
    }
    return samples;
  }

  /** Helper: create 16kHz mono PCM buffer. */
  function make16kMono(samples: number[]): ArrayBuffer {
    const buf = new ArrayBuffer(samples.length * 2);
    const view = new DataView(buf);
    for (let i = 0; i < samples.length; i++) {
      view.setInt16(i * 2, samples[i], true);
    }
    return buf;
  }

  /** Helper: read 48kHz stereo samples as [left, right] pairs. */
  function read48kStereo(buf: Uint8Array): [number, number][] {
    const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
    const pairs: [number, number][] = [];
    for (let i = 0; i < buf.byteLength; i += 4) {
      pairs.push([view.getInt16(i, true), view.getInt16(i + 2, true)]);
    }
    return pairs;
  }

  describe("downsample48kStereoTo16kMono", () => {
    it("converts identical L+R channels to mono (average = same value)", () => {
      const stereo = make48kStereo([1000, -500, 0, 32767]);
      const mono = downsample48kStereoTo16kMono(stereo);
      const samples = read16kMono(mono);

      // Each group of 3 identical stereo pairs → 1 mono sample
      // With identical L+R, average = original value
      expect(samples).toEqual([1000, -500, 0, 32767]);
    });

    it("averages left and right channels", () => {
      // Create stereo with different L and R
      const buf = new ArrayBuffer(3 * 4); // 3 stereo pairs → 1 output sample
      const view = new DataView(buf);
      // Only first pair matters for downsampling (sample at index 0*3 = 0)
      view.setInt16(0, 100, true); // left
      view.setInt16(2, 200, true); // right
      // Fill remaining pairs
      view.setInt16(4, 100, true);
      view.setInt16(6, 200, true);
      view.setInt16(8, 100, true);
      view.setInt16(10, 200, true);

      const mono = downsample48kStereoTo16kMono(new Uint8Array(buf));
      const samples = read16kMono(mono);
      expect(samples[0]).toBe(150); // (100 + 200) / 2
    });

    it("returns empty buffer for empty input", () => {
      const mono = downsample48kStereoTo16kMono(new Uint8Array(0));
      expect(mono.byteLength).toBe(0);
    });

    it("handles input shorter than one output sample", () => {
      // 2 stereo pairs (need 3 for one output sample)
      const buf = new Uint8Array(2 * 4);
      const mono = downsample48kStereoTo16kMono(buf);
      expect(mono.byteLength).toBe(0);
    });
  });

  describe("upsample16kMonoTo48kStereo", () => {
    it("duplicates mono samples to stereo pairs (3x)", () => {
      const mono = make16kMono([1000, -500]);
      const stereo = upsample16kMonoTo48kStereo(mono);
      const pairs = read48kStereo(stereo);

      // 2 mono samples → 6 stereo pairs
      expect(pairs.length).toBe(6);
      // First 3 pairs should be [1000, 1000]
      expect(pairs[0]).toEqual([1000, 1000]);
      expect(pairs[1]).toEqual([1000, 1000]);
      expect(pairs[2]).toEqual([1000, 1000]);
      // Next 3 pairs should be [-500, -500]
      expect(pairs[3]).toEqual([-500, -500]);
      expect(pairs[4]).toEqual([-500, -500]);
      expect(pairs[5]).toEqual([-500, -500]);
    });

    it("returns empty buffer for empty input", () => {
      const stereo = upsample16kMonoTo48kStereo(new ArrayBuffer(0));
      expect(stereo.length).toBe(0);
    });

    it("handles single sample", () => {
      const mono = make16kMono([42]);
      const stereo = upsample16kMonoTo48kStereo(mono);
      const pairs = read48kStereo(stereo);
      expect(pairs.length).toBe(3);
      for (const [l, r] of pairs) {
        expect(l).toBe(42);
        expect(r).toBe(42);
      }
    });
  });

  describe("downsample ↔ upsample roundtrip", () => {
    it("recovers original samples through upsample → downsample", () => {
      // Start with 16kHz mono, upsample to 48kHz stereo, downsample back
      const original = [100, -200, 300, 0, -32768, 32767];
      const mono = make16kMono(original);
      const stereo = upsample16kMonoTo48kStereo(mono);
      const roundtripped = downsample48kStereoTo16kMono(stereo);
      const result = read16kMono(roundtripped);

      expect(result).toEqual(original);
    });
  });
});