import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { CaptchaAudioRenderer } from "../types.ts"; interface ParsedWav { sampleRate: number; channels: number; bitsPerSample: number; data: Uint8Array; } function u16(view: DataView, offset: number): number { return view.getUint16(offset, true); } function u32(view: DataView, offset: number): number { return view.getUint32(offset, true); } function parseWav(bytes: Uint8Array): ParsedWav { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (bytes.length < 44 || String.fromCharCode(...bytes.slice(0, 4)) !== "RIFF") { throw new Error("CAPTCHA audio asset is not a WAV file"); } let offset = 12; let sampleRate = 0; let channels = 0; let bitsPerSample = 0; let data: Uint8Array | undefined; while (offset + 8 <= bytes.length) { const id = String.fromCharCode(...bytes.slice(offset, offset + 4)); const size = u32(view, offset + 4); const start = offset + 8; if (id === "fmt ") { const format = u16(view, start); if (format !== 1) throw new Error("CAPTCHA audio assets must use PCM WAV"); channels = u16(view, start + 2); sampleRate = u32(view, start + 4); bitsPerSample = u16(view, start + 14); } else if (id === "data") { data = bytes.slice(start, start + size); } offset = start + size + (size % 2); } if (!sampleRate || !channels || !bitsPerSample || !data) { throw new Error("Invalid CAPTCHA WAV asset"); } return { sampleRate, channels, bitsPerSample, data }; } function writeAscii(target: Uint8Array, offset: number, value: string): void { for (let index = 0; index < value.length; index++) { target[offset + index] = value.charCodeAt(index); } } function createWav( parts: Uint8Array[], sampleRate: number, channels: number, bitsPerSample: number, ): Uint8Array { const dataLength = parts.reduce((sum, part) => sum + part.length, 0); const out = new Uint8Array(44 + dataLength); const view = new DataView(out.buffer); const blockAlign = channels * (bitsPerSample / 8); const byteRate = sampleRate * blockAlign; writeAscii(out, 0, "RIFF"); view.setUint32(4, 36 + dataLength, true); writeAscii(out, 8, "WAVE"); writeAscii(out, 12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, channels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, byteRate, true); view.setUint16(32, blockAlign, true); view.setUint16(34, bitsPerSample, true); writeAscii(out, 36, "data"); view.setUint32(40, dataLength, true); let cursor = 44; for (const part of parts) { out.set(part, cursor); cursor += part.length; } return out; } function safeToken(value: string): string { const token = value.toLowerCase().trim(); if (!/^[a-z0-9]+$/.test(token)) { throw new Error(`Unsupported audio token: ${value}`); } return token; } function addCandidate(candidates: string[], value: string | undefined): void { if (!value) return; const normalized = value.trim(); if (normalized && !candidates.includes(normalized)) candidates.push(normalized); } /** * Resolve the bundled audio directory in source, workspace, installed-package, * and bundled-server layouts. A production server bundle changes import.meta.url, * so package resolution and cwd fallbacks are required in addition to the * source-relative path. */ export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string { const candidates: string[] = []; addCandidate(candidates, explicitDir); addCandidate(candidates, process.env.WRNEXUS_CAPTCHA_AUDIO_DIR); try { const packageEntry = createRequire(import.meta.url).resolve("@wrnexus/captcha/audio"); addCandidate(candidates, join(dirname(dirname(dirname(packageEntry))), "assets", "audio")); } catch { // The source-relative and cwd fallbacks below still support direct source use. } const sourcePackageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); addCandidate(candidates, join(sourcePackageRoot, "assets", "audio")); let current = process.cwd(); for (let depth = 0; depth < 8; depth += 1) { addCandidate(candidates, join(current, "packages", "captcha", "assets", "audio")); addCandidate( candidates, join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"), ); const parent = dirname(current); if (parent === current) break; current = parent; } for (const candidate of candidates) { if (existsSync(join(candidate, "en"))) return candidate; } throw new Error( [ "Unable to locate WRNexusJS CAPTCHA audio assets.", "Set WRNEXUS_CAPTCHA_AUDIO_DIR or pass assetsDir to AssetAudioRenderer.", `Checked: ${candidates.join(", ")}`, ].join(" "), ); } export interface AssetAudioRendererOptions { assetsDir?: string; gapMs?: number; } /** * Concatenates bundled English PCM word clips. Applications can replace this * renderer with cloud TTS or their own localized renderer without changing the * challenge engine. */ export class AssetAudioRenderer implements CaptchaAudioRenderer { readonly contentType = "audio/wav"; private readonly assetsDir: string; private readonly gapMs: number; private readonly cache = new Map(); constructor(options: AssetAudioRendererOptions = {}) { this.assetsDir = resolveCaptchaAudioAssetsDir(options.assetsDir); this.gapMs = options.gapMs ?? 180; } async render(sequence: string[], locale: string): Promise { const language = locale.toLowerCase().split("-")[0] || "en"; if (language !== "en") { throw new Error( `No bundled CAPTCHA audio assets for locale '${locale}'. Supply a custom CaptchaAudioRenderer.`, ); } if (!sequence.length) throw new Error("Cannot render an empty CAPTCHA audio sequence"); const clips = await Promise.all(sequence.map((token) => this.load(language, safeToken(token)))); const first = clips[0]!; for (const clip of clips) { if ( clip.sampleRate !== first.sampleRate || clip.channels !== first.channels || clip.bitsPerSample !== first.bitsPerSample ) { throw new Error("CAPTCHA audio assets must share one PCM format"); } } const bytesPerSample = first.channels * (first.bitsPerSample / 8); const silenceBytes = Math.floor((first.sampleRate * this.gapMs) / 1000) * bytesPerSample; const silence = new Uint8Array(silenceBytes); const parts: Uint8Array[] = []; for (let index = 0; index < clips.length; index += 1) { if (index) parts.push(silence); parts.push(clips[index]!.data); } return createWav(parts, first.sampleRate, first.channels, first.bitsPerSample); } private async load(language: string, token: string): Promise { const key = `${language}/${token}`; const cached = this.cache.get(key); if (cached) return cached; const bytes = new Uint8Array(await readFile(join(this.assetsDir, language, `${token}.wav`))); const parsed = parseWav(bytes); this.cache.set(key, parsed); return parsed; } } export function createAssetAudioRenderer(options?: AssetAudioRendererOptions): AssetAudioRenderer { return new AssetAudioRenderer(options); }