998d37410c79

add ggwave-wasm loader and ChirpCodec wrapper for ultrasonic-fastest pairing channel

time
2026-05-18 02:09 UTC · 5 months ago
machine
koh2
parent
304bf8470f7e
2 files src/shared/ggwave.test.ts+131 −0src/shared/ggwave.ts+187 −0
src/shared/ggwave.test.ts+131 −0
@@ -0,0 +1,131 @@1+import { describe, expect, it, beforeEach, afterEach } from "vitest";2+import { CHIRP_PAYLOAD_LENGTH } from "./sync-types";3+import {4+  __setGgwaveModuleForTest,5+  createChirpCodec,6+  GgwaveProtocol,7+  loadGgwave,8+} from "./ggwave";9+10+function fakeGgwaveModule() {11+  const initParams: unknown[] = [];12+  const encodeCalls: Array<{ payload: Uint8Array; protocol: number; volume: number }> = [];13+  const decodeCalls: Float32Array[] = [];14+  let nextInstance = 1;15+  const freed: number[] = [];16+17+  return {18+    initParams,19+    encodeCalls,20+    decodeCalls,21+    freed,22+    mod: {23+      ProtocolId: { GGWAVE_PROTOCOL_ULTRASOUND_FASTEST: 5 },24+      GGWAVE_OPERATING_MODE_RX: 1,25+      GGWAVE_OPERATING_MODE_TX: 2,26+      GGWAVE_OPERATING_MODE_RX_AND_TX: 3,27+      getDefaultParameters() {28+        return {29+          sampleRateInp: 48000,30+          sampleRateOut: 48000,31+          samplesPerFrame: 1024,32+          soundMarkerThreshold: 3,33+          payloadLength: -1,34+          operatingMode: 0,35+        };36+      },37+      init(p: unknown) {38+        initParams.push(p);39+        return nextInstance++;40+      },41+      free(instance: number) {42+        freed.push(instance);43+      },44+      encode(_inst: number, data: Uint8Array | string, protocol: number, volume: number) {45+        const payload = typeof data === "string" ? new TextEncoder().encode(data) : data;46+        encodeCalls.push({ payload, protocol, volume });47+        return new Float32Array(payload.length * 100); // synthetic waveform48+      },49+      decode(_inst: number, samples: Float32Array | Int16Array) {50+        decodeCalls.push(samples as Float32Array);51+        // half the time, "succeed"; tests inject deterministic samples52+        if (samples.length === 999) return null;53+        return new Uint8Array(CHIRP_PAYLOAD_LENGTH); // empty-but-correct-length54+      },55+    },56+  };57+}58+59+describe("ggwave loader", () => {60+  beforeEach(() => __setGgwaveModuleForTest(null));61+  afterEach(() => __setGgwaveModuleForTest(null));62+63+  it("loadGgwave returns the test-injected module", async () => {64+    const fake = fakeGgwaveModule();65+    __setGgwaveModuleForTest(fake.mod);66+    const mod = await loadGgwave();67+    expect(mod).toBe(fake.mod);68+  });69+70+  it("createChirpCodec configures sample rate, payload length, RX+TX mode", async () => {71+    const fake = fakeGgwaveModule();72+    __setGgwaveModuleForTest(fake.mod);73+    await createChirpCodec({ sampleRate: 44100 });74+    expect(fake.initParams).toHaveLength(1);75+    const p = fake.initParams[0] as Record<string, number>;76+    expect(p.sampleRateInp).toBe(44100);77+    expect(p.sampleRateOut).toBe(44100);78+    expect(p.payloadLength).toBe(CHIRP_PAYLOAD_LENGTH);79+    expect(p.operatingMode).toBe(3); // RX_AND_TX80+  });81+82+  it("encode forwards the payload to ggwave with ultrasonic-fastest protocol and 10% volume by default", async () => {83+    const fake = fakeGgwaveModule();84+    __setGgwaveModuleForTest(fake.mod);85+    const codec = await createChirpCodec({ sampleRate: 48000 });86+    const payload = new Uint8Array(CHIRP_PAYLOAD_LENGTH);87+    payload[0] = 0x50;88+    const wave = codec.encode(payload);89+    expect(wave.length).toBeGreaterThan(0);90+    expect(fake.encodeCalls).toHaveLength(1);91+    expect(fake.encodeCalls[0].payload).toEqual(payload);92+    expect(fake.encodeCalls[0].protocol).toBe(GgwaveProtocol.ULTRASOUND_FASTEST);93+    expect(fake.encodeCalls[0].volume).toBe(10);94+  });95+96+  it("encode rejects wrong-length payload", async () => {97+    const fake = fakeGgwaveModule();98+    __setGgwaveModuleForTest(fake.mod);99+    const codec = await createChirpCodec({ sampleRate: 48000 });100+    expect(() => codec.encode(new Uint8Array(63))).toThrow();101+  });102+103+  it("decode forwards samples and returns the payload bytes", async () => {104+    const fake = fakeGgwaveModule();105+    __setGgwaveModuleForTest(fake.mod);106+    const codec = await createChirpCodec({ sampleRate: 48000 });107+    const samples = new Float32Array(2048);108+    const decoded = codec.decode(samples);109+    expect(decoded).toBeInstanceOf(Uint8Array);110+    expect(decoded!.length).toBe(CHIRP_PAYLOAD_LENGTH);111+  });112+113+  it("decode returns null when ggwave returns null (no chirp detected)", async () => {114+    const fake = fakeGgwaveModule();115+    __setGgwaveModuleForTest(fake.mod);116+    const codec = await createChirpCodec({ sampleRate: 48000 });117+    const samples = new Float32Array(999);118+    expect(codec.decode(samples)).toBeNull();119+  });120+121+  it("free is idempotent and prevents further encode/decode", async () => {122+    const fake = fakeGgwaveModule();123+    __setGgwaveModuleForTest(fake.mod);124+    const codec = await createChirpCodec({ sampleRate: 48000 });125+    codec.free();126+    codec.free();127+    expect(fake.freed).toEqual([1]);128+    expect(() => codec.encode(new Uint8Array(CHIRP_PAYLOAD_LENGTH))).toThrow();129+    expect(() => codec.decode(new Float32Array(0))).toThrow();130+  });131+});

view file

src/shared/ggwave.ts+187 −0
@@ -0,0 +1,187 @@1+// ggwave loader — thin async wrapper around the ggwave-wasm module.2+//3+// ggwave is loaded from a vendored copy at `/ggwave/ggwave.js` rather than4+// npm-imported. Two reasons:5+//6+//   1. The browser-extension service worker can't import arbitrary npm7+//      modules at runtime — every dependency has to be bundled. ggwave-wasm8+//      ships a JS+WASM pair where the JS glue is small and the WASM blob9+//      is ~150KB; vendoring keeps the bundle deterministic and dodges a10+//      transitive-dep audit on every release.11+//12+//   2. The same loader works in popup pages, content scripts, and on the13+//      Capacitor mobile build — anywhere we can fetch a static asset.14+//15+// The loader is idempotent: repeated `loadGgwave()` calls share one16+// instance. The instance owns a per-AudioContext sample rate and must be17+// rebuilt if the AudioContext rate changes between calls (which it never18+// should during the lifetime of a pairing screen).19+20+import { CHIRP_PAYLOAD_LENGTH } from "./sync-types";21+22+// ggwave-wasm ProtocolId enum subset. We only ever use the ultrasonic23+// fastest mode — see brief §3.1 — but the others are listed so the type24+// covers the upstream API surface we forward.25+export const enum GgwaveProtocol {26+  AUDIBLE_NORMAL = 0,27+  AUDIBLE_FAST = 1,28+  AUDIBLE_FASTEST = 2,29+  ULTRASOUND_NORMAL = 3,30+  ULTRASOUND_FAST = 4,31+  ULTRASOUND_FASTEST = 5, // GGWAVE_PROTOCOL_MT_FASTEST equivalent — chirp default32+  DT_NORMAL = 6,33+  DT_FAST = 7,34+  DT_FASTEST = 8,35+}36+37+interface GgwaveParameters {38+  sampleRateInp: number;39+  sampleRateOut: number;40+  samplesPerFrame: number;41+  soundMarkerThreshold: number;42+  payloadLength: number;43+  operatingMode: number;44+}45+46+interface GgwaveModule {47+  ProtocolId: Record<string, number>;48+  GGWAVE_OPERATING_MODE_RX: number;49+  GGWAVE_OPERATING_MODE_TX: number;50+  GGWAVE_OPERATING_MODE_RX_AND_TX: number;51+  getDefaultParameters(): GgwaveParameters;52+  init(p: GgwaveParameters): number; // instance handle53+  free(instance: number): void;54+  encode(instance: number, data: Uint8Array | string, protocol: number, volumePercent: number): Float32Array;55+  decode(instance: number, samples: Float32Array | Int16Array): Uint8Array | null;56+}57+58+type GgwaveFactory = () => Promise<GgwaveModule>;59+60+declare global {61+  interface Window {62+    ggwave_factory?: GgwaveFactory;63+  }64+  // eslint-disable-next-line no-var65+  var ggwave_factory: GgwaveFactory | undefined;66+}67+68+let modulePromise: Promise<GgwaveModule> | null = null;69+70+/**71+ * Locate the ggwave JS glue. Picked relative to the extension URL when72+ * running inside Chrome/Firefox; falls back to a relative path so the same73+ * loader works in the Capacitor mobile build.74+ */75+function ggwaveScriptUrl(): string {76+  if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {77+    return chrome.runtime.getURL("ggwave/ggwave.js");78+  }79+  return "./ggwave/ggwave.js";80+}81+82+async function injectScript(url: string): Promise<void> {83+  // Service worker / module worker: importScripts is unavailable in MV384+  // workers, but `import(url)` always works for a same-origin ES module.85+  // The vendored ggwave.js is wrapped as a side-effecting ES module that86+  // assigns globalThis.ggwave_factory on evaluation.87+  await import(/* @vite-ignore */ url);88+}89+90+export async function loadGgwave(): Promise<GgwaveModule> {91+  if (modulePromise) return modulePromise;92+  modulePromise = (async () => {93+    if (typeof globalThis.ggwave_factory !== "function") {94+      try {95+        await injectScript(ggwaveScriptUrl());96+      } catch (err) {97+        throw new Error(98+          `ggwave glue could not be loaded — vendored asset missing? ${(err as Error).message}`,99+        );100+      }101+    }102+    const factory = globalThis.ggwave_factory;103+    if (typeof factory !== "function") {104+      throw new Error("ggwave_factory not exposed after script load");105+    }106+    return factory();107+  })();108+  return modulePromise;109+}110+111+/**112+ * High-level chirp codec bound to a specific AudioContext sample rate.113+ * One instance is held by the pairing coordinator for the entire pairing114+ * session and discarded on completion.115+ *116+ * Volume is fixed at 10% — ggwave's published guidance for ultrasonic117+ * pairing on internal speakers. Raising it makes pairing more reliable in118+ * noisy rooms but pumps audible artefacts at the low edge of the FSK band119+ * on some laptop speakers.120+ */121+export interface ChirpCodec {122+  /** Encode a 64-byte chirp payload into a mono float32 waveform. */123+  encode(payload: Uint8Array): Float32Array;124+  /** Try to decode a single chirp out of a buffer of input samples. */125+  decode(samples: Float32Array): Uint8Array | null;126+  /** Free the underlying ggwave instance. Idempotent. */127+  free(): void;128+}129+130+export interface ChirpCodecOptions {131+  sampleRate: number;132+  protocol?: GgwaveProtocol;133+  volumePercent?: number;134+}135+136+export async function createChirpCodec(opts: ChirpCodecOptions): Promise<ChirpCodec> {137+  const mod = await loadGgwave();138+  const params = mod.getDefaultParameters();139+  params.sampleRateInp = opts.sampleRate;140+  params.sampleRateOut = opts.sampleRate;141+  params.payloadLength = CHIRP_PAYLOAD_LENGTH;142+  params.operatingMode = mod.GGWAVE_OPERATING_MODE_RX_AND_TX;143+  const protocol = opts.protocol ?? GgwaveProtocol.ULTRASOUND_FASTEST;144+  const volume = opts.volumePercent ?? 10;145+  let instance: number | null = mod.init(params);146+147+  return {148+    encode(payload: Uint8Array): Float32Array {149+      if (instance === null) throw new Error("chirp codec is freed");150+      if (payload.length !== CHIRP_PAYLOAD_LENGTH) {151+        throw new Error(`chirp payload must be ${CHIRP_PAYLOAD_LENGTH} bytes`);152+      }153+      const result = mod.encode(instance, payload, protocol, volume);154+      if (!result || result.length === 0) {155+        throw new Error("ggwave.encode returned an empty waveform");156+      }157+      return result;158+    },159+    decode(samples: Float32Array): Uint8Array | null {160+      if (instance === null) throw new Error("chirp codec is freed");161+      const decoded = mod.decode(instance, samples);162+      if (!decoded) return null;163+      // ggwave returns the exact payload bytes — caller validates the164+      // PEAC magic via decodeChirpPayload.165+      return new Uint8Array(decoded);166+    },167+    free(): void {168+      if (instance !== null) {169+        mod.free(instance);170+        instance = null;171+      }172+    },173+  };174+}175+176+/**177+ * Test-only override for the ggwave module. The chirp pairing tests inject178+ * a fake module so they can exercise the play/listen loop without the179+ * vendored WASM blob.180+ */181+export function __setGgwaveModuleForTest(mod: GgwaveModule | null): void {182+  if (mod === null) {183+    modulePromise = null;184+    return;185+  }186+  modulePromise = Promise.resolve(mod);187+}

view file