Fix Vosklet browser cache handling and recognizer event bridge

- fix broken cached model loading
- cache validated TAR models instead of gzip responses
- add proper fireEv bridge for wasm/js events
- fix recognizer initialization callbacks
- fix Embind waveform return type handling
- stabilize acceptWaveform in minified builds
- add optional debug logging
This commit is contained in:
2026-05-12 19:25:31 +02:00
parent 166ecc501b
commit 90e4390d1a
9 changed files with 1800 additions and 108 deletions

1643
Vosklet.js

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,4 +1,5 @@
#include "CommonModel.h"
#include "FireEv.h"
CommonModel::CommonModel(int index, bool normalMdl, int tarStart, int tarSize) : normalMdl{normalMdl}, index{index}
{

18
src/FireEv.cc Normal file
View File

@@ -0,0 +1,18 @@
#include <emscripten.h>
EM_JS(void, fireEv, (int idx, const char *msgPtr), {
const msg = msgPtr ? UTF8ToString(msgPtr) : null;
const obj =
globalThis.__voskletObjs &&
globalThis.__voskletObjs[idx];
if (!obj) {
console.error("fireEv: unknown object index", idx);
return;
}
obj.dispatchEvent(new CustomEvent("", {
detail: msg
}));
});

12
src/FireEv.h Normal file
View File

@@ -0,0 +1,12 @@
// FireEv.h
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
void fireEv(int idx, const char *msgPtr);
#ifdef __cplusplus
}
#endif

View File

@@ -1,40 +1,54 @@
#include "Recognizer.h"
#include "FireEv.h"
#include <atomic>
const char *recognizerInitErr{"Unable to initialize recognizer"};
Recognizer::Recognizer(int index, float sampleRate, CommonModel *model) : rec{vosk_recognizer_new(std::get<VoskModel *>(model->mdl), sampleRate)}
{
if (rec == nullptr)
{
fireEv(index, recognizerInitErr);
}
else
fireEv(index);
}
Recognizer::Recognizer(int index, float sampleRate, CommonModel *model, CommonModel *spkModel) : rec{vosk_recognizer_new_spk(std::get<VoskModel *>(model->mdl), sampleRate, std::get<VoskSpkModel *>(spkModel->mdl))}
{
if (rec == nullptr)
{
fireEv(index, recognizerInitErr);
}
else
fireEv(index);
}
Recognizer::Recognizer(int index, float sampleRate, CommonModel *model, const std::string &grm, int) : rec{vosk_recognizer_new_grm(std::get<VoskModel *>(model->mdl), sampleRate, grm.c_str())}
{
if (rec == nullptr)
{
fireEv(index, recognizerInitErr);
}
else
fireEv(index);
}
const char *Recognizer::acceptWaveform(int start, int len)
std::string Recognizer::acceptWaveform(int start, int len)
{
switch (vosk_recognizer_accept_waveform_f(rec, reinterpret_cast<float *>(start), len))
const char *res = nullptr;
switch (vosk_recognizer_accept_waveform_f(
rec,
reinterpret_cast<float *>(start),
len))
{
case 0:
return vosk_recognizer_partial_result(rec);
res = vosk_recognizer_partial_result(rec);
break;
case 1:
return vosk_recognizer_result(rec);
res = vosk_recognizer_result(rec);
break;
default:
res = "";
break;
}
return nullptr;
return res ? std::string(res) : std::string();
}
void Recognizer::reset()
{

View File

@@ -11,7 +11,7 @@ struct Recognizer
Recognizer(int index, float sampleRate, CommonModel *model);
Recognizer(int index, float sampleRate, CommonModel *model, CommonModel *spkModel);
Recognizer(int index, float sampleRate, CommonModel *model, const std::string &grm, int);
const char *acceptWaveform(int start, int len);
std::string acceptWaveform(int start, int len);
void reset();
void setEndpointerMode(VoskEndpointerMode mode);
void setEndpointerDelays(float tStartMax, float tEnd, float tMax);

View File

@@ -7,6 +7,22 @@ if (ENVIRONMENT_IS_WEB) {
// 'var' to expose this outside the if
var objs = [];
globalThis.__voskletObjs = objs;
const VOSKLET_DEBUG = false;
function voskLog(...args) {
if (VOSKLET_DEBUG) {
console.log(...args);
}
}
function voskWarn(...args) {
if (VOSKLET_DEBUG) {
console.warn(...args);
}
}
var events = ['status', 'partialResult', 'result'];
let _cache = caches.open('Vosklet');
let processorURL = URL.createObjectURL(new Blob(['(', (() => {
@@ -39,8 +55,31 @@ if (ENVIRONMENT_IS_WEB) {
delete() {
this.obj.delete();
}
static isGzip(bytes) {
return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
}
static isTar(bytes) {
return (
bytes.length > 262 &&
bytes[257] === 0x75 &&
bytes[258] === 0x73 &&
bytes[259] === 0x74 &&
bytes[260] === 0x61 &&
bytes[261] === 0x72
);
}
static async gunzipArrayBuffer(buffer) {
const ds = new DecompressionStream('gzip');
const stream = new Response(buffer).body.pipeThrough(ds);
return await new Response(stream).arrayBuffer();
}
static async mk(url, storepath, id, normalMdl) {
let mdl = new CommonModel();
let result = new Promise((resolve, reject) => {
mdl.addEventListener('', ev => {
if (!ev.detail) {
@@ -50,59 +89,151 @@ if (ENVIRONMENT_IS_WEB) {
else reject(ev.detail);
}, { once: true });
});
let cache = await caches.open('Vosklet');
let req = (await cache.keys(storepath, { ignoreSearch: true }))[0];
let res;
if (typeof req == 'undefined' || req.url.split('?')[1] != id) {
// Caching already handled explicitly
res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw 'Unable to fetch model, status: ' + res.status;
res = new Response(res.body.pipeThrough(new DecompressionStream('gzip')));
await cache.put(storepath + '?' + id, res.clone());
const cache = await caches.open('Vosklet');
const cacheKey = storepath + '?' + id;
let res = await cache.match(cacheKey);
let tar;
if (res) {
tar = await res.arrayBuffer();
if (tar.byteLength === 0 || !CommonModel.isTar(new Uint8Array(tar))) {
voskWarn('Vosklet: kaputter Cache-Eintrag, lösche...');
await cache.delete(cacheKey);
tar = null;
}
else res = await cache.match(req);
let tar = await res.arrayBuffer();
}
if (!tar) {
voskLog("Vosklet: fetch start");
let fetchRes = await fetch(url, { cache: 'no-store' });
voskLog("Vosklet: fetch ok", fetchRes.status);
if (!fetchRes.ok) {
throw 'Unable to fetch model, status: ' + fetchRes.status;
}
let buf = await fetchRes.arrayBuffer();
voskLog("Vosklet: arrayBuffer size", buf.byteLength);
let bytes = new Uint8Array(buf);
voskLog("Vosklet: gzip?", CommonModel.isGzip(bytes));
voskLog("Vosklet: tar?", CommonModel.isTar(bytes));
if (bytes.byteLength === 0) {
throw 'Vosklet: Modell-Download ergab 0 Bytes.';
}
if (CommonModel.isGzip(bytes)) {
buf = await CommonModel.gunzipArrayBuffer(buf);
bytes = new Uint8Array(buf);
}
if (!CommonModel.isTar(bytes)) {
throw 'Vosklet: Modell ist kein gültiges USTAR/TAR.';
}
voskLog("Vosklet: cache put start");
await cache.put(
cacheKey,
new Response(buf, {
headers: {
'Content-Type': 'application/x-tar',
'X-Vosklet-Format': 'tar',
'X-Vosklet-Model-Id': String(id)
}
})
);
voskLog("Vosklet: cache put done");
tar = buf;
}
voskLog("Vosklet: malloc start", tar.byteLength);
let tarStart = _malloc(tar.byteLength);
voskLog("Vosklet: malloc done", tarStart);
voskLog("Vosklet: HEAPU8.set start");
HEAPU8.set(new Uint8Array(tar), tarStart);
mdl.obj = new Module['CommonModel'](objs.length - 1, normalMdl, tarStart, tar.byteLength);
voskLog("Vosklet: HEAPU8.set done");
voskLog("Vosklet: CommonModel constructor start");
mdl.obj = new Module['CommonModel'](
objs.length - 1,
normalMdl,
tarStart,
tar.byteLength
);
voskLog("Vosklet: CommonModel constructor returned");
return result;
}
}
class Recognizer extends EventTarget {
constructor() {
super();
objs.push(this);
}
acceptWaveform(audioData) {
this['acceptWaveform'] = (audioData) => {
let start = _malloc(audioData.length * 4);
HEAPF32.set(audioData, start / 4);
return UTF8ToString(this.obj['acceptWaveform'](start, audioData.length));
return this.obj['acceptWaveform'](start, audioData.length);
};
}
delete() {
this.obj.delete();
}
static async mk(model, sampleRate, mode, grammar, spkModel) {
let rec = new Recognizer();
let result = new Promise((resolve, reject) => {
rec.addEventListener('', ev => {
if (!ev.detail) resolve(rec);
else reject(ev.detail);
if (!ev.detail) {
resolve(rec);
} else {
reject(ev.detail);
}
}, { once: true });
})
});
switch (mode) {
case 1:
rec.obj = new Module['Recognizer'](objs.length - 1, sampleRate, model);
rec.obj = new Module['Recognizer'](
objs.length - 1,
sampleRate,
model
);
break;
case 2:
rec.obj = new Module['Recognizer'](objs.length - 1, sampleRate, model, spkModel);
rec.obj = new Module['Recognizer'](
objs.length - 1,
sampleRate,
model,
spkModel
);
break;
default:
rec.obj = new Module['Recognizer'](objs.length - 1, sampleRate, model, grammar, 0);
rec.obj = new Module['Recognizer'](
objs.length - 1,
sampleRate,
model,
grammar,
0
);
}
return result;
}
}
Module = {
'getModelCache': () => _cache,

View File

@@ -37,17 +37,18 @@ VOSK=$(realpath vosk)
OPENFST=$(realpath openfst)
OPENBLAS=$(realpath openblas)
SHARED_FLAGS="-g0 -O3 -flto -msimd128 -matomics -mreference-types -mextended-const -msign-ext -mmutable-globals"
#SHARED_FLAGS="-g0 -O3 -flto -msimd128 -matomics -mreference-types -mextended-const -msign-ext -mmutable-globals"
SHARED_FLAGS=" -msimd128 -matomics -mreference-types -mextended-const -msign-ext -mmutable-globals"
if [ ! -f "$OPENFST/lib/libfst.a" ]; then
rm -rf /tmp/openfst &&
wget https://www.openfst.org/twiki/pub/FST/FstDownload/openfst-1.8.4.tar.gz -O /tmp/openfst.tgz &&
mkdir /tmp/openfst &&
tar -xzf /tmp/openfst.tgz -C /tmp/openfst --strip-component 1 &&
tar --no-same-owner -xzf /tmp/openfst.tgz -C /tmp/openfst --strip-component 1 &&
cd /tmp/openfst &&
autoreconf -is &&
CXXFLAGS="$SHARED_FLAGS -O3 -fno-rtti" emconfigure ./configure --prefix="$OPENFST" --enable-static --disable-shared --enable-ngram-fsts --disable-bin &&
emmake make -j"$JOBS" install > /dev/null &&
emmake make -j"$JOBS" install
fi
if [ ! -f "$OPENBLAS/lib/libopenblas.a" ]; then
rm -rf /tmp/openblas &&
@@ -56,10 +57,10 @@ if [ ! -f "$OPENBLAS/lib/libopenblas.a" ]; then
git apply "$SRC"/OpenBLAS.patch &&
# Change HOSTCC to the default C compiler on your machine
openblasFlags="CC=emcc HOSTCC=clang-20 TARGET=RISCV64_GENERIC USE_THREAD=0 NO_SHARED=1 BINARY=32 BUILD_SINGLE=1 BUILD_DOUBLE=1 BUILD_BFLOAT16=0 BUILD_COMPLEX16=0 BUILD_COMPLEX=0"
openblasFlags="CC=emcc HOSTCC=clang-19 TARGET=RISCV64_GENERIC USE_THREAD=0 NO_SHARED=1 BINARY=32 BUILD_SINGLE=1 BUILD_DOUBLE=1 BUILD_BFLOAT16=0 BUILD_COMPLEX16=0 BUILD_COMPLEX=0"
openblasCFlags="$SHARED_FLAGS -fno-exceptions -fno-rtti -Wno-implicit-function-declaration -Wno-unused-function -Wno-unused-but-set-variable"
make $openblasFlags CFLAGS="$openblasCFlags" PREFIX="$OPENBLAS" -j"$JOBS" > /dev/null &&
make $openblasFlags CFLAGS="$openblasCFlags" PREFIX="$OPENBLAS" -j"$JOBS" install &&
make $openblasFlags CFLAGS="$openblasCFlags" PREFIX="$OPENBLAS" -j"$JOBS" install
fi
if [ ! -f "$KALDI/src/kaldi.mk" ]; then
@@ -82,7 +83,7 @@ if [ ! -f "$VOSK/src/vosk.a" ]; then
fi
cd "$SRC" &&
voskletFiles="Util.o CommonModel.o Recognizer.o Bindings.o"
voskletFiles="Util.o CommonModel.o Recognizer.o Bindings.o FireEv.o"
voskletFlags="$SHARED_FLAGS -fno-rtti -sSTRICT -sWASM_WORKERS"
voskletLDFlags="-sWASMFS -sMODULARIZE -sTEXTDECODER=2 -sEVAL_CTORS=2 -sALLOW_UNIMPLEMENTED_SYSCALLS -sINITIAL_MEMORY=$INITIAL_MEMORY -sALLOW_MEMORY_GROWTH -sPOLYFILL=0 -sEXIT_RUNTIME=0 -sINVOKE_RUN=0 -sSUPPORT_LONGJMP=0 -sINCOMING_MODULE_JS_API=wasmMemory,instantiateWasm,wasm -sEXPORT_NAME=loadVosklet -sMALLOC=emmalloc -sENVIRONMENT=web,worker -L$KALDI/src -l:online2/kaldi-online2.a -l:decoder/kaldi-decoder.a -l:ivector/kaldi-ivector.a -l:gmm/kaldi-gmm.a -l:tree/kaldi-tree.a -l:feat/kaldi-feat.a -l:cudamatrix/kaldi-cudamatrix.a -l:lat/kaldi-lat.a -l:lm/kaldi-lm.a -l:rnnlm/kaldi-rnnlm.a -l:hmm/kaldi-hmm.a -l:nnet3/kaldi-nnet3.a -l:transform/kaldi-transform.a -l:matrix/kaldi-matrix.a -l:fstext/kaldi-fstext.a -l:util/kaldi-util.a -l:base/kaldi-base.a -L$OPENFST/lib -l:libfst.a -l:libfstngram.a -L$OPENBLAS -l:lib/libopenblas.a -L$VOSK/src -l:vosk.a -lembind --no-entry --closure 1 --pre-js Wrapper.js"