1234567891011121314151617181920212223import { generateKeyPair } from "@stablelib/x25519";24import { decodeChirpPayload, ChirpDecodeError } from "./chirp-payload";25import { createChirpCodec } from "./ggwave";26import {27 CHIRP_PROTOCOL_VERSION,28 type ChirpPayload,29 type DeviceTypeByte,30 CHIRP_PAYLOAD_LENGTH,31} from "./sync-types";32import { encodeChirpPayload } from "./chirp-payload";3334const CHIRP_LOOP_INTERVAL_MS = 4_000;35const DEFAULT_TIMEOUT_MS = 90_000;36const LISTEN_FRAME_SAMPLES = 4096;3738export interface PairingAttempt {39 ephemeralPrivateKey: Uint8Array; 40 ephemeralPublicKey: Uint8Array;41 sessionNonce: Uint8Array; 42 outgoingPayload: ChirpPayload;43}4445export function createPairingAttempt(deviceType: DeviceTypeByte): PairingAttempt {46 const kp = generateKeyPair();47 const nonce = new Uint8Array(16);48 cryptoFill(nonce);49 return {50 ephemeralPrivateKey: kp.secretKey,51 ephemeralPublicKey: kp.publicKey,52 sessionNonce: nonce,53 outgoingPayload: {54 version: CHIRP_PROTOCOL_VERSION,55 deviceType,56 ephemeralPublicKey: kp.publicKey,57 sessionNonce: nonce,58 },59 };60}6162function cryptoFill(buf: Uint8Array): void {63 if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {64 crypto.getRandomValues(buf as Uint8Array<ArrayBuffer>);65 return;66 }67 68 69 70 71 throw new Error("crypto.getRandomValues unavailable");72}7374export interface ChirpExchangeResult {75 remoteChirp: ChirpPayload;76}7778export interface ChirpExchangeOptions {79 attempt: PairingAttempt;80 timeoutMs?: number;81 82 onDiscard?: (reason: ChirpDecodeError["kind"]) => void;83 84 onChirpPlayed?: (count: number) => void;85}86878889909192export interface AudioIO {93 sampleRate: number;94 play(waveform: Float32Array): Promise<void>;95 pullSamples(): Promise<Float32Array>;96 close(): Promise<void>;97}9899100101102103104105106107108export async function runChirpExchange(109 io: AudioIO,110 opts: ChirpExchangeOptions,111): Promise<ChirpExchangeResult> {112 const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;113 const codec = await createChirpCodec({ sampleRate: io.sampleRate });114 const ourBytes = encodeChirpPayload(opts.attempt.outgoingPayload);115 const ourWaveform = codec.encode(ourBytes);116117 const deadline = Date.now() + timeoutMs;118 let playCount = 0;119 let stopped = false;120 let wakePlayLoop: (() => void) | null = null;121122 function abortPlayLoop() {123 stopped = true;124 if (wakePlayLoop) wakePlayLoop();125 }126127 function interruptibleSleep(ms: number): Promise<void> {128 return new Promise<void>((resolve) => {129 const t = setTimeout(resolve, ms);130 wakePlayLoop = () => { clearTimeout(t); resolve(); };131 });132 }133134 const playLoop = (async () => {135 while (!stopped && Date.now() < deadline) {136 try {137 await io.play(ourWaveform);138 playCount++;139 opts.onChirpPlayed?.(playCount);140 } catch {141 142 143 }144 if (stopped) break;145 const remaining = deadline - Date.now();146 const wait = Math.min(CHIRP_LOOP_INTERVAL_MS, Math.max(0, remaining));147 if (wait <= 0) break;148 await interruptibleSleep(wait);149 }150 })();151152 try {153 while (Date.now() < deadline && !stopped) {154 const samples = await io.pullSamples();155 if (samples.length === 0) {156 await sleep(50);157 continue;158 }159 const decoded = codec.decode(samples);160 if (!decoded) continue;161 if (decoded.length !== CHIRP_PAYLOAD_LENGTH) {162 opts.onDiscard?.("length");163 continue;164 }165 try {166 const payload = decodeChirpPayload(decoded);167 168 169 if (sameBytes(payload.ephemeralPublicKey, opts.attempt.ephemeralPublicKey)) {170 continue;171 }172 if (payload.version !== CHIRP_PROTOCOL_VERSION) {173 throw new ChirpExchangeError("version_mismatch");174 }175 stopped = true;176 return { remoteChirp: payload };177 } catch (err) {178 if (err instanceof ChirpExchangeError) throw err;179 if (err instanceof ChirpDecodeError) {180 if (err.kind === "version") {181 182 183 184 185 throw new ChirpExchangeError("version_mismatch");186 }187 opts.onDiscard?.(err.kind);188 continue;189 }190 throw err;191 }192 }193 throw new ChirpExchangeError("timeout");194 } finally {195 abortPlayLoop();196 codec.free();197 await playLoop.catch(() => undefined);198 await io.close().catch(() => undefined);199 }200}201202export class ChirpExchangeError extends Error {203 readonly kind: "timeout" | "version_mismatch" | "mic_denied" | "audio_unavailable";204 constructor(kind: ChirpExchangeError["kind"], message?: string) {205 super(message ?? kind);206 this.kind = kind;207 }208}209210function sameBytes(a: Uint8Array, b: Uint8Array): boolean {211 if (a.length !== b.length) return false;212 for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;213 return true;214}215216function sleep(ms: number): Promise<void> {217 return new Promise((res) => setTimeout(res, ms));218}219220221222223224225226227228229230export async function openBrowserAudioIO(): Promise<AudioIO> {231 if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {232 throw new ChirpExchangeError("audio_unavailable", "getUserMedia not available");233 }234 const ctx: AudioContext = new (globalThis.AudioContext ??235 (globalThis as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();236 let stream: MediaStream;237 try {238 stream = await navigator.mediaDevices.getUserMedia({239 audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },240 });241 } catch (err) {242 await ctx.close();243 throw new ChirpExchangeError("mic_denied", (err as Error).message);244 }245 const source = ctx.createMediaStreamSource(stream);246247 248 249 250 251 252 const processor = ctx.createScriptProcessor(LISTEN_FRAME_SAMPLES, 1, 1);253 let accumulator: Float32Array = new Float32Array(0);254255 processor.onaudioprocess = (ev) => {256 const input = ev.inputBuffer.getChannelData(0);257 const next = new Float32Array(accumulator.length + input.length);258 next.set(accumulator, 0);259 next.set(input, accumulator.length);260 accumulator = next;261 };262263 source.connect(processor);264 processor.connect(ctx.destination);265266 return {267 sampleRate: ctx.sampleRate,268 async play(waveform: Float32Array): Promise<void> {269 const buffer = ctx.createBuffer(1, waveform.length, ctx.sampleRate);270 buffer.copyToChannel(waveform as Float32Array<ArrayBuffer>, 0, 0);271 const src = ctx.createBufferSource();272 src.buffer = buffer;273 src.connect(ctx.destination);274 await new Promise<void>((resolve) => {275 src.onended = () => resolve();276 src.start();277 });278 },279 async pullSamples(): Promise<Float32Array> {280 const out = accumulator;281 accumulator = new Float32Array(0);282 return out;283 },284 async close(): Promise<void> {285 processor.disconnect();286 source.disconnect();287 for (const track of stream.getTracks()) track.stop();288 await ctx.close();289 },290 };291}