hama-js
Version:
G2P, phoneme-ASR, and P2G inference for Node, Bun, and browsers, powered by a self-contained WASM engine (no onnxruntime).
406 lines • 17.1 kB
JavaScript
import vocabData from "./assets/g2p_vocab.json";
import p2gVocabData from "./assets/p2g_vocab.json";
import { ASR_OUTPUT_FRAME_SAMPLES, ctcPhonemeSpans } from "./ctc.js";
export { ctcPhonemeSpans, ASR_OUTPUT_FRAME_SAMPLES } from "./ctc.js";
import { ASR_VOCAB, HamaEngine } from "./engine.js";
import { loadWasm, resolveModelBytes } from "./engine.browser.js";
import { decodeP2GOutput, normalizePhonemeTokens } from "./p2g-text.js";
import { buildDisplayIpa, decodeIdsToResult, decoderIds, encodeText, prepareTextForPrediction, } from "./tokenizer.js";
import { pronunciationReplaceWithModel, pronunciationScanWithModel, } from "./pronunciation.js";
let defaultPronunciationBrowserModelPromise = null;
let browserEnginePromise = null;
const getBrowserEngine = () => {
if (browserEnginePromise == null)
browserEnginePromise = loadWasm().then((w) => HamaEngine.fromBytes(w));
return browserEnginePromise;
};
export const decodeCtcTokens = (frameTokenIds, decoderTokens, options) => {
const collapseRepeats = options.collapseRepeats ?? true;
const tokenIds = [];
let prev = -1;
for (const tokenId of frameTokenIds) {
if (collapseRepeats && tokenId === prev) {
continue;
}
prev = tokenId;
if (tokenId === options.blankId) {
continue;
}
tokenIds.push(tokenId);
}
const rawTokens = tokenIds.map((tokenId) => decoderTokens[tokenId] ?? "<unk>");
const phonemes = rawTokens.filter((token) => token !== options.wordBoundaryToken);
const words = [];
let current = [];
for (const token of rawTokens) {
if (token === options.wordBoundaryToken) {
if (current.length > 0) {
words.push(current);
current = [];
}
continue;
}
current.push(token);
}
if (current.length > 0) {
words.push(current);
}
return { tokenIds, phonemes, words };
};
const loadDecoderTokens = async (vocabUrl) => {
let vocab;
if (vocabUrl) {
const response = await fetch(vocabUrl);
if (!response.ok) {
throw new Error(`Failed to fetch ASR vocab: ${response.status} ${response.statusText}`);
}
vocab = await response.json();
}
else {
vocab = vocabData;
}
if (!Array.isArray(vocab.decoder) || vocab.decoder.length === 0) {
throw new Error("Invalid vocabulary JSON: missing decoder token list");
}
return [...vocab.decoder, "<wb>", "<blank>"];
};
export class G2PBrowserModel {
constructor(engine, encHandle, decHandle, options) {
this.engine = engine;
this.encHandle = encHandle;
this.decHandle = decHandle;
this.options = options;
}
static async create(options = {}) {
if ((options.encoderUrl === undefined) !== (options.decoderStepUrl === undefined)) {
throw new Error("encoderUrl and decoderStepUrl must be provided together");
}
const opts = {
modelUrl: options.modelUrl ?? "",
encoderUrl: options.encoderUrl ?? "",
decoderStepUrl: options.decoderStepUrl ?? "",
maxInputLen: options.maxInputLen ?? 128,
maxOutputLen: options.maxOutputLen ?? 32,
};
const engine = await getBrowserEngine();
const [encBytes, decBytes] = await Promise.all([
resolveModelBytes(options.encoderUrl, "encoder.hama"),
resolveModelBytes(options.decoderStepUrl, "decoder_step.hama"),
]);
return new G2PBrowserModel(engine, engine.loadEncoder(encBytes), engine.loadDecoder(decBytes), opts);
}
async predict(text, options = {}) {
const splitDelimiter = options.splitDelimiter ?? /\s+/u;
const outputDelimiter = options.outputDelimiter ?? " ";
const preserveLiterals = options.preserveLiterals ?? "none";
const segments = splitSegments(text, splitDelimiter);
if (segments.length === 0) {
return this.predictSingle(text, 0, preserveLiterals);
}
const results = await Promise.all(segments.map(async (segment) => this.predictSingle(segment.text, codePointOffset(text, segment.startCodeUnit), preserveLiterals)));
const ipaParts = [];
const displayParts = [];
const alignments = [];
for (let i = 0; i < results.length; i++) {
if (i > 0)
ipaParts.push(outputDelimiter);
if (i > 0)
displayParts.push(outputDelimiter);
ipaParts.push(results[i].ipa);
displayParts.push(results[i].displayIpa);
for (const alignment of results[i].alignments) {
alignments.push({
phoneme: alignment.phoneme,
phonemeIndex: alignments.length,
charIndex: alignment.charIndex,
});
}
}
return { ipa: ipaParts.join(""), displayIpa: displayParts.join(""), alignments };
}
async pronunciationScan(text, terms, options = {}) {
return pronunciationScanWithModel(this, text, terms, options);
}
async pronunciationReplace(text, terms, options = {}) {
return pronunciationReplaceWithModel(this, text, terms, options);
}
getMaxInputLen() {
return this.options.maxInputLen;
}
async predictSingle(text, baseCharIndex, preserveLiterals) {
const prepared = prepareTextForPrediction(text, preserveLiterals);
if (preserveLiterals === "punct" && !/\S/u.test(prepared.modelText)) {
return {
ipa: "",
displayIpa: Array.from(text).filter((ch) => /\p{P}/u.test(ch)).join(""),
alignments: [],
};
}
return this.predictSingleSplit(prepared.modelText, text, prepared.charIndexMap, baseCharIndex, preserveLiterals);
}
predictSingleSplit(text, originalText, charIndexMap, baseCharIndex, preserveLiterals) {
const encoded = encodeText(text, this.options.maxInputLen);
const inputIds = BigInt64Array.from(encoded.ids);
const enc = this.engine.encoderRun(this.encHandle, inputIds, encoded.length || 1);
const positions = new Float32Array(enc.T);
for (let i = 0; i < enc.T; i++)
positions[i] = i;
let hidden = enc.hidden;
let prevAttn = enc.prevAttn;
let token = decoderIds.sos;
const decoded = [];
const attnIndices = [];
for (let step = 0; step < this.options.maxOutputLen; step++) {
const out = this.engine.decoderStep(this.decHandle, token, enc.encoderOutputs, enc.projectedKeys, enc.mask, prevAttn, hidden, positions);
decoded.push(out.nextToken);
attnIndices.push(out.attnArgmax);
hidden = out.hiddenOut;
prevAttn = out.prevOut;
token = out.nextToken;
if (token === decoderIds.eos)
break;
}
const rawResult = decodeIdsToResult(decoded, attnIndices, encoded.positionMap);
const relativeAlignments = rawResult.alignments.map((alignment, idx) => ({
phoneme: alignment.phoneme,
phonemeIndex: idx,
charIndex: alignment.charIndex >= 0 && alignment.charIndex < charIndexMap.length
? charIndexMap[alignment.charIndex]
: -1,
}));
return {
ipa: rawResult.ipa,
displayIpa: preserveLiterals === "punct"
? buildDisplayIpa(rawResult.ipa, relativeAlignments, originalText)
: rawResult.ipa,
alignments: relativeAlignments.map((alignment, idx) => ({
phoneme: alignment.phoneme,
phonemeIndex: idx,
charIndex: alignment.charIndex < 0 ? alignment.charIndex : alignment.charIndex + baseCharIndex,
})),
};
}
}
export class ASRBrowserModel {
constructor(engine, handle, decoderTokens, options) {
this.engine = engine;
this.handle = handle;
this.sampleRate = options.sampleRate;
this.wordBoundaryToken = options.wordBoundaryToken;
this.temperature = options.temperature;
this.blankBias = options.blankBias;
this.unkBias = options.unkBias;
this.collapseRepeats = options.collapseRepeats;
this.decoderTokens = decoderTokens;
this.blankId = this.decoderTokens.indexOf(options.blankToken);
if (this.blankId < 0) {
throw new Error(`blank token '${options.blankToken}' not found in decoder tokens`);
}
const unk = this.decoderTokens.indexOf(options.unkToken);
this.unkId = unk >= 0 ? unk : null;
}
static async create(options = {}) {
const engine = await getBrowserEngine();
const [decoderTokens, modelBytes] = await Promise.all([
loadDecoderTokens(options.vocabUrl),
resolveModelBytes(options.modelUrl, "asr_waveform.hama"),
]);
const handle = engine.loadAsr(modelBytes);
return new ASRBrowserModel(engine, handle, decoderTokens, {
sampleRate: options.sampleRate ?? 16000,
blankToken: options.blankToken ?? "<blank>",
unkToken: options.unkToken ?? "<unk>",
wordBoundaryToken: options.wordBoundaryToken ?? "<wb>",
temperature: options.temperature ?? 0.95,
blankBias: options.blankBias ?? -0.1,
unkBias: options.unkBias ?? 0.0,
collapseRepeats: options.collapseRepeats ?? true,
});
}
get inputFormat() {
return "waveform";
}
async transcribeWaveform(waveform, sampleRate) {
const mono = toFloat32Mono(waveform);
const resampled = sampleRate === this.sampleRate
? mono
: resampleLinear(mono, sampleRate, this.sampleRate);
const { logProbs, T, outLength } = this.engine.asrRun(this.handle, resampled);
const numFrames = Math.max(0, Math.min(outLength, T));
const frameTokenIds = this.argmaxFrames(logProbs, numFrames);
const decoded = decodeCtcTokens(frameTokenIds, this.decoderTokens, {
blankId: this.blankId,
wordBoundaryToken: this.wordBoundaryToken,
collapseRepeats: this.collapseRepeats,
});
return {
phonemes: decoded.phonemes,
phonemeText: decoded.phonemes.join(" "),
wordPhonemeText: decoded.words.map((word) => word.join(" ")).join(" | "),
tokenIds: decoded.tokenIds,
frameTokenIds,
numFrames,
};
}
/** Approximate per-phoneme time spans (ms) from an ASRResult. */
phonemeSpans(result) {
return ctcPhonemeSpans(result.frameTokenIds, this.decoderTokens, {
blankId: this.blankId,
wordBoundaryToken: this.wordBoundaryToken,
frameMs: (1000 * ASR_OUTPUT_FRAME_SAMPLES) / this.sampleRate,
collapseRepeats: this.collapseRepeats,
});
}
argmaxFrames(logProbs, numFrames) {
const classes = ASR_VOCAB;
const out = [];
for (let t = 0; t < numFrames; t++) {
let bestIdx = 0;
let bestScore = -Infinity;
const base = t * classes;
for (let c = 0; c < classes; c++) {
const raw = logProbs[base + c];
const score = (this.temperature > 0 && Math.abs(this.temperature - 1.0) > 1e-8 ? raw / this.temperature : raw)
+ (c === this.blankId ? this.blankBias : 0.0)
+ (this.unkId !== null && c === this.unkId ? this.unkBias : 0.0);
if (score > bestScore) {
bestScore = score;
bestIdx = c;
}
}
out.push(bestIdx);
}
return out;
}
}
const P2G_MAX_INPUT_LEN = 192;
const P2G_MAX_OUTPUT_LEN = 192;
const P2G_MAX_SEQUENCE_LEN = 416;
export class P2GBrowserModel {
constructor(engine, handle, tokens) {
this.engine = engine;
this.handle = handle;
this.tokens = tokens;
this.token2id = new Map(tokens.map((t, i) => [t, i]));
this.bos = this.token2id.get("<bos>");
this.src = this.token2id.get("<src>");
this.tgt = this.token2id.get("<tgt>");
this.eos = this.token2id.get("<eos>");
this.pad = this.token2id.get("<pad>");
this.unk = this.token2id.get("<unk>");
}
static async create(options = {}) {
const engine = await getBrowserEngine();
const handle = engine.loadP2g(await resolveModelBytes(options.modelUrl, "p2g.hama"));
let tokens = p2gVocabData.tokens;
if (options.vocabUrl) {
const res = await fetch(options.vocabUrl);
tokens = (await res.json()).tokens;
}
return new P2GBrowserModel(engine, handle, tokens.map(String));
}
predict(phonemes) {
const source = normalizePhonemeTokens(phonemes).slice(0, P2G_MAX_INPUT_LEN);
if (source.length === 0)
source.push("<unk>");
let prefix = [this.bos, this.src, ...source.map((t) => this.token2id.get(t) ?? this.unk), this.tgt];
if (prefix.length >= P2G_MAX_SEQUENCE_LEN) {
prefix = [...prefix.slice(0, P2G_MAX_SEQUENCE_LEN - 1), this.tgt];
}
const maxNew = Math.min(P2G_MAX_OUTPUT_LEN + 1, P2G_MAX_SEQUENCE_LEN - prefix.length);
const { ids, align } = this.engine.p2gGreedyAlign(this.handle, BigInt64Array.from(prefix, BigInt), maxNew, this.eos, this.pad);
return decodeP2GOutput(ids, align, this.tokens, source);
}
}
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const toGlobalRegex = (delimiter) => {
if (typeof delimiter === "string") {
if (delimiter.length === 0) {
throw new Error("splitDelimiter must not be an empty string");
}
return new RegExp(escapeRegex(delimiter), "gu");
}
const flags = delimiter.flags.includes("g") ? delimiter.flags : `${delimiter.flags}g`;
return new RegExp(delimiter.source, flags.includes("u") ? flags : `${flags}u`);
};
const splitSegments = (text, delimiter) => {
if (delimiter === null) {
return [{ text, startCodeUnit: 0 }];
}
const regex = toGlobalRegex(delimiter);
if (regex.test("")) {
throw new Error("splitDelimiter must not match an empty string");
}
regex.lastIndex = 0;
const segments = [];
let start = 0;
for (const match of text.matchAll(regex)) {
const end = match.index ?? 0;
if (end > start) {
segments.push({ text: text.slice(start, end), startCodeUnit: start });
}
start = end + match[0].length;
}
if (start < text.length) {
segments.push({ text: text.slice(start), startCodeUnit: start });
}
return segments;
};
const codePointOffset = (text, codeUnitOffset) => {
let codePointIndex = 0;
let i = 0;
while (i < codeUnitOffset) {
const cp = text.codePointAt(i) ?? 0;
i += cp > 0xffff ? 2 : 1;
codePointIndex += 1;
}
return codePointIndex;
};
const getDefaultPronunciationBrowserModel = () => {
if (defaultPronunciationBrowserModelPromise == null) {
defaultPronunciationBrowserModelPromise = G2PBrowserModel.create();
}
return defaultPronunciationBrowserModelPromise;
};
export const pronunciationScan = async (text, terms, options = {}) => pronunciationScanWithModel(await getDefaultPronunciationBrowserModel(), text, terms, options);
export const pronunciationReplace = async (text, terms, options = {}) => pronunciationReplaceWithModel(await getDefaultPronunciationBrowserModel(), text, terms, options);
const toFloat32Mono = (waveform) => {
if (waveform instanceof Float32Array) {
return clampUnit(waveform);
}
if (!Array.isArray(waveform)) {
throw new Error("waveform must be Float32Array or number[]");
}
const out = new Float32Array(waveform.length);
for (let i = 0; i < waveform.length; i++) {
out[i] = Number(waveform[i]);
}
return clampUnit(out);
};
const clampUnit = (arr) => {
const out = new Float32Array(arr.length);
for (let i = 0; i < arr.length; i++) {
const v = arr[i];
out[i] = v < -1 ? -1 : v > 1 ? 1 : v;
}
return out;
};
const resampleLinear = (waveform, srcRate, dstRate) => {
if (srcRate === dstRate)
return waveform;
if (waveform.length === 0)
return waveform;
const duration = (waveform.length - 1) / srcRate;
const dstLen = Math.max(1, Math.round(duration * dstRate) + 1);
const out = new Float32Array(dstLen);
for (let i = 0; i < dstLen; i++) {
const t = i / dstRate;
const srcPos = t * srcRate;
const i0 = Math.floor(srcPos);
const i1 = Math.min(waveform.length - 1, i0 + 1);
const a = srcPos - i0;
out[i] = waveform[i0] * (1 - a) + waveform[i1] * a;
}
return out;
};
//# sourceMappingURL=browser.js.map