234 lines
7.3 KiB
TypeScript
234 lines
7.3 KiB
TypeScript
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<string, ParsedWav>();
|
|
|
|
constructor(options: AssetAudioRendererOptions = {}) {
|
|
this.assetsDir = resolveCaptchaAudioAssetsDir(options.assetsDir);
|
|
this.gapMs = options.gapMs ?? 180;
|
|
}
|
|
|
|
async render(sequence: string[], locale: string): Promise<Uint8Array> {
|
|
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<ParsedWav> {
|
|
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);
|
|
}
|