ggwave.ts

6.5 KB · raw

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).1920import { CHIRP_PAYLOAD_LENGTH } from "./sync-types";2122// 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.25export 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}3637interface GgwaveParameters {38  sampleRateInp: number;39  sampleRateOut: number;40  samplesPerFrame: number;41  soundMarkerThreshold: number;42  payloadLength: number;43  operatingMode: number;44}4546interface 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}5758type GgwaveFactory = () => Promise<GgwaveModule>;5960declare global {61  interface Window {62    ggwave_factory?: GgwaveFactory;63  }64  // eslint-disable-next-line no-var65  var ggwave_factory: GgwaveFactory | undefined;66}6768let modulePromise: Promise<GgwaveModule> | null = null;6970/**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 */75function 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}8182async 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}8990export 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}110111/**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 */121export 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}129130export interface ChirpCodecOptions {131  sampleRate: number;132  protocol?: GgwaveProtocol;133  volumePercent?: number;134}135136export 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);146147  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}175176/**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 */181export function __setGgwaveModuleForTest(mod: GgwaveModule | null): void {182  if (mod === null) {183    modulePromise = null;184    return;185  }186  modulePromise = Promise.resolve(mod);187}