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
↓ peach-browser-998d3741.tar.gzatlas at this save
files changed2 files
src/shared/ggwave.test.ts+131 −0src/shared/ggwave.ts+187 −0src/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+});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+}