// ggwave loader — thin async wrapper around the ggwave-wasm module. // // ggwave is loaded from a vendored copy at `/ggwave/ggwave.js` rather than // npm-imported. Two reasons: // // 1. The browser-extension service worker can't import arbitrary npm // modules at runtime — every dependency has to be bundled. ggwave-wasm // ships a JS+WASM pair where the JS glue is small and the WASM blob // is ~150KB; vendoring keeps the bundle deterministic and dodges a // transitive-dep audit on every release. // // 2. The same loader works in popup pages, content scripts, and on the // Capacitor mobile build — anywhere we can fetch a static asset. // // The loader is idempotent: repeated `loadGgwave()` calls share one // instance. The instance owns a per-AudioContext sample rate and must be // rebuilt if the AudioContext rate changes between calls (which it never // should during the lifetime of a pairing screen). import { CHIRP_PAYLOAD_LENGTH } from "./sync-types"; // ggwave-wasm ProtocolId enum subset. We only ever use the ultrasonic // fastest mode — see brief §3.1 — but the others are listed so the type // covers the upstream API surface we forward. export const enum GgwaveProtocol { AUDIBLE_NORMAL = 0, AUDIBLE_FAST = 1, AUDIBLE_FASTEST = 2, ULTRASOUND_NORMAL = 3, ULTRASOUND_FAST = 4, ULTRASOUND_FASTEST = 5, // GGWAVE_PROTOCOL_MT_FASTEST equivalent — chirp default DT_NORMAL = 6, DT_FAST = 7, DT_FASTEST = 8, } interface GgwaveParameters { sampleRateInp: number; sampleRateOut: number; samplesPerFrame: number; soundMarkerThreshold: number; payloadLength: number; operatingMode: number; } interface GgwaveModule { ProtocolId: Record; GGWAVE_OPERATING_MODE_RX: number; GGWAVE_OPERATING_MODE_TX: number; GGWAVE_OPERATING_MODE_RX_AND_TX: number; getDefaultParameters(): GgwaveParameters; init(p: GgwaveParameters): number; // instance handle free(instance: number): void; encode(instance: number, data: Uint8Array | string, protocol: number, volumePercent: number): Float32Array; decode(instance: number, samples: Float32Array | Int16Array): Uint8Array | null; } type GgwaveFactory = () => Promise; declare global { interface Window { ggwave_factory?: GgwaveFactory; } // eslint-disable-next-line no-var var ggwave_factory: GgwaveFactory | undefined; } let modulePromise: Promise | null = null; /** * Locate the ggwave JS glue. Picked relative to the extension URL when * running inside Chrome/Firefox; falls back to a relative path so the same * loader works in the Capacitor mobile build. */ function ggwaveScriptUrl(): string { if (typeof chrome !== "undefined" && chrome.runtime?.getURL) { return chrome.runtime.getURL("ggwave/ggwave.js"); } return "./ggwave/ggwave.js"; } async function injectScript(url: string): Promise { // Service worker / module worker: importScripts is unavailable in MV3 // workers, but `import(url)` always works for a same-origin ES module. // The vendored ggwave.js is wrapped as a side-effecting ES module that // assigns globalThis.ggwave_factory on evaluation. await import(/* @vite-ignore */ url); } export async function loadGgwave(): Promise { if (modulePromise) return modulePromise; modulePromise = (async () => { if (typeof globalThis.ggwave_factory !== "function") { try { await injectScript(ggwaveScriptUrl()); } catch (err) { throw new Error( `ggwave glue could not be loaded — vendored asset missing? ${(err as Error).message}`, ); } } const factory = globalThis.ggwave_factory; if (typeof factory !== "function") { throw new Error("ggwave_factory not exposed after script load"); } return factory(); })(); return modulePromise; } /** * High-level chirp codec bound to a specific AudioContext sample rate. * One instance is held by the pairing coordinator for the entire pairing * session and discarded on completion. * * Volume is fixed at 10% — ggwave's published guidance for ultrasonic * pairing on internal speakers. Raising it makes pairing more reliable in * noisy rooms but pumps audible artefacts at the low edge of the FSK band * on some laptop speakers. */ export interface ChirpCodec { /** Encode a 64-byte chirp payload into a mono float32 waveform. */ encode(payload: Uint8Array): Float32Array; /** Try to decode a single chirp out of a buffer of input samples. */ decode(samples: Float32Array): Uint8Array | null; /** Free the underlying ggwave instance. Idempotent. */ free(): void; } export interface ChirpCodecOptions { sampleRate: number; protocol?: GgwaveProtocol; volumePercent?: number; } export async function createChirpCodec(opts: ChirpCodecOptions): Promise { const mod = await loadGgwave(); const params = mod.getDefaultParameters(); params.sampleRateInp = opts.sampleRate; params.sampleRateOut = opts.sampleRate; params.payloadLength = CHIRP_PAYLOAD_LENGTH; params.operatingMode = mod.GGWAVE_OPERATING_MODE_RX_AND_TX; const protocol = opts.protocol ?? GgwaveProtocol.ULTRASOUND_FASTEST; const volume = opts.volumePercent ?? 10; let instance: number | null = mod.init(params); return { encode(payload: Uint8Array): Float32Array { if (instance === null) throw new Error("chirp codec is freed"); if (payload.length !== CHIRP_PAYLOAD_LENGTH) { throw new Error(`chirp payload must be ${CHIRP_PAYLOAD_LENGTH} bytes`); } const result = mod.encode(instance, payload, protocol, volume); if (!result || result.length === 0) { throw new Error("ggwave.encode returned an empty waveform"); } return result; }, decode(samples: Float32Array): Uint8Array | null { if (instance === null) throw new Error("chirp codec is freed"); const decoded = mod.decode(instance, samples); if (!decoded) return null; // ggwave returns the exact payload bytes — caller validates the // PEAC magic via decodeChirpPayload. return new Uint8Array(decoded); }, free(): void { if (instance !== null) { mod.free(instance); instance = null; } }, }; } /** * Test-only override for the ggwave module. The chirp pairing tests inject * a fake module so they can exercise the play/listen loop without the * vendored WASM blob. */ export function __setGgwaveModuleForTest(mod: GgwaveModule | null): void { if (mod === null) { modulePromise = null; return; } modulePromise = Promise.resolve(mod); }