AudioDecisionModel / inference-worker.js
gojiteji's picture
Rename model link and refresh demo header and loading progress
c9f9bbf verified
Raw History Blame Contribute Delete
25.4 kB
import * as ort from './vendor/ort.webgpu.bundle.min.mjs';
import {Tokenizer} from './vendor/tokenizers.mjs';
import {WhisperFeatureExtractor, ClapFeatureExtractor} from './vendor/transformers.web.min.js';
ort.env.wasm.wasmPaths = new URL('./vendor/', import.meta.url).href;
ort.env.wasm.numThreads = 1;
ort.env.wasm.proxy = false;
let model = null;
let loading = null;
let languageLoading = null;
let soundLoading = null;
let modelFileSizes = {};
let currentId = null;
const textCache = new Map();
const truncatedTexts = new Set();
const progress = message => postMessage({id: currentId, progress: message});
const BROAD = ['animal', 'nature', 'music', 'environment'];
const ANIMAL8 = ['dog', 'cat', 'bird', 'insect', 'chicken_and_rooster', 'crow', 'frog', 'cricket'];
const ALIASES = {
animal: ['animal sound', 'animal', '動物音', '動物の音', '動物の鳴き声'],
nature: ['nature sound', 'nature', '自然音', '自然の音'],
music: ['music', 'musical', '音楽'],
environment: ['environmental sound', 'environment sound', 'environment', '環境音', '環境の音'],
dog: ['dog', '犬', 'いぬ', '犬の鳴き声'], cat: ['cat', '猫', 'ねこ', '猫の鳴き声'],
bird: ['bird', '鳥', 'とり', '鳥の鳴き声'], insect: ['insect', '虫', '昆虫', '虫の音'],
chicken_and_rooster: ['chicken', 'rooster', 'hen', '鶏', 'にわとり', 'ニワトリ', '雄鶏'],
crow: ['crow', 'カラス', 'からす'], frog: ['frog', 'カエル', 'かえる'],
cricket: ['cricket', 'コオロギ', 'こおろぎ'],
};
const OR_CUES = ['または', 'もしくは', 'あるいは', 'いずれか', 'or', 'either'];
const NEGATIVE_CUES = ['含まれない', '聞こえない', '存在しない', '該当しない', '満たさない', 'ではない', 'なし', 'ない', 'without', 'absent', 'not', 'no '];
async function asset(path) {
const response = await fetch(new URL(path, import.meta.url));
if (!response.ok) throw new Error(`モデルファイルの取得に失敗しました (${response.status})。Spaceへのアクセス権を確認してください。`);
return response;
}
async function modelBytes(path, label) {
const response = await asset(path);
const filename = path.split('/').at(-1).split('?')[0];
const total = Number(modelFileSizes[filename]?.bytes || response.headers.get('Content-Length') || 0);
const report = received => {
const amount = (received / 1e6).toFixed(1);
progress(total
? `${label}を端末に読み込み中… ${Math.min(100, Math.round(received / total * 100))}%(${amount} / ${(total / 1e6).toFixed(1)} MB)`
: `${label}を端末に読み込み中… ${amount} MB`);
};
report(0);
if (!response.body) {
const bytes = new Uint8Array(await response.arrayBuffer());
report(bytes.length);
return bytes;
}
const reader = response.body.getReader();
const chunks = []; let received = 0; let last = performance.now();
while (true) {
const {done, value} = await reader.read(); if (done) break;
chunks.push(value); received += value.length;
if (performance.now() - last > 400) {
report(received);
last = performance.now();
}
}
const result = new Uint8Array(received); let offset = 0;
for (const chunk of chunks) {result.set(chunk, offset); offset += chunk.length;}
report(received);
return result;
}
async function createSession(path, label) {
const bytes = await modelBytes(path, label);
progress(`${label}を準備中…`);
return ort.InferenceSession.create(bytes, {
executionProviders: ['wasm'], graphOptimizationLevel: 'all',
});
}
async function load() {
if (model?.audio && model?.head && model?.sound) return status();
if (loading) return loading;
loading = (async () => {
if (!model) {
const manifest = await (await asset('./models/manifest.json')).json();
modelFileSizes = manifest.files || {};
progress('Edge AIモデルを端末に読み込みます。');
const text = await createSession('./models/text_q8.onnx', '質問モデル');
const routerCore = await createSession('./models/modality_router_core_fp32.onnx?v=20260921-router-ensemble-web-v1', 'ルーティングモデル 1/2');
const routerNatural = await createSession('./models/modality_router_natural_fp32.onnx?v=20260921-router-ensemble-web-v1', 'ルーティングモデル 2/2');
const [tokenizerJson, tokenizerConfig, qwenConfig, clapConfig] = await Promise.all([
'tokenizer.json', 'tokenizer_config.json',
].map(async name => (await asset('./models/tokenizer/' + name)).json()).concat([
asset('./models/qwen_preprocessor_config.json').then(response => response.json()),
asset('./models/clap_preprocessor_config.json').then(response => response.json()),
]));
model = {
audio: null, text, head: null, routers: [routerCore, routerNatural], sound: null,
tokenizer: new Tokenizer(tokenizerJson, tokenizerConfig),
qwenExtractor: new WhisperFeatureExtractor(qwenConfig),
clapExtractor: new ClapFeatureExtractor(clapConfig),
manifest, backend: 'wasm', precision: 'int8',
};
}
await loadLanguage();
await loadSound();
progress('モデルの準備ができました。データはこの端末内だけで処理されます。');
return status();
})();
try {return await loading;} finally {loading = null;}
}
async function loadLanguage() {
if (model.audio && model.head) return {audio: model.audio, head: model.head};
if (languageLoading) return languageLoading;
languageLoading = (async () => {
progress('発話モデルを端末に読み込み中…');
const audio = await createSession('./models/qwen_audio_q8.onnx', '発話モデル');
const head = await createSession('./models/qwen_typed_head_q8.onnx?v=20260921-noulmatch-v4', '発話の判断モデル');
model.audio = audio; model.head = head;
return {audio, head};
})();
try {return await languageLoading;} finally {languageLoading = null;}
}
async function loadSound() {
if (model.sound) return model.sound;
if (soundLoading) return soundLoading;
soundLoading = (async () => {
progress('非言語モデルを端末に読み込み中…');
const audio = await createSession('./models/clap_audio_q8.onnx', '非言語モデル');
const heads = await createSession('./models/clap_heads_q8.onnx', '非言語の判断モデル');
model.sound = {audio, heads};
return model.sound;
})();
try {return await soundLoading;} finally {soundLoading = null;}
}
function status() {
return {ready: true, backend: model.backend, precision: model.precision,
model: model.manifest.model, on_device: true, language_ready: Boolean(model.audio), sound_ready: Boolean(model.sound)};
}
function optionsFor(q) {
if (!q || typeof q !== 'object' || typeof q.instructions !== 'string' || !q.instructions.trim()) throw new Error('各質問にinstructions(質問文)を指定してください。');
if (q.instructions.length > 2000) throw new Error('質問文は2000文字以内にしてください。');
let pairs;
if (q.type === 'noul') {
const c = q.criteria ?? {false: 'いいえ。その条件を満たさない。', true: 'はい。その条件を満たす。'};
if (!c || Array.isArray(c) || Object.keys(c).sort().join(',') !== 'false,true') throw new Error('Noulのcriteriaにはfalseとtrueを指定してください。');
pairs = [['false', c.false], ['true', c.true]];
} else if (q.type === 'choice') {
if (!q.criteria || typeof q.criteria !== 'object' || Array.isArray(q.criteria)) throw new Error('Choiceのcriteriaは候補IDと説明のオブジェクトです。');
pairs = Object.entries(q.criteria);
if (pairs.length < 2 || pairs.length > 32) throw new Error('Choiceの候補数は2〜32個です。');
} else if (q.type === 'score') {
if (!Array.isArray(q.criteria) || q.criteria.length < 2 || q.criteria.length > 10) throw new Error('Scoreのcriteriaは2〜10段階の説明を順に並べた配列です。');
pairs = q.criteria.map((value, index) => [String(index), value]);
} else throw new Error('typeはnoul・choice・scoreのいずれかです。');
if (pairs.some(([key, value]) => !key || typeof value !== 'string' || !value.trim() || value.length > 1000)) throw new Error('各候補には空でない説明を1000文字以内で指定してください。');
return pairs;
}
function contains(text, alias) {
const value = text.toLocaleLowerCase().replace(/\s+/g, ' ').trim();
const target = alias.toLocaleLowerCase().replace(/\s+/g, ' ').trim();
if (/^[\x00-\x7F]+$/.test(target) && /[a-z]/.test(target)) {
const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(^|[^a-z])${escaped}([^a-z]|$)`).test(value);
}
return value.includes(target);
}
function labelsIn(text, allowed) {
return allowed.filter(label => ALIASES[label].some(alias => contains(text, alias)));
}
function hasAny(text, cues) { return cues.some(cue => contains(text, cue)); }
function operatorFor(text) { return hasAny(text, OR_CUES) ? 'or' : 'and'; }
function isNegative(text) { return hasAny(text, NEGATIVE_CUES); }
function softmax(values) {
const maximum = Math.max(...values);
const exponentials = values.map(value => Math.exp(value - maximum));
const total = exponentials.reduce((sum, value) => sum + value, 0);
return exponentials.map(value => value / total);
}
async function learnedRoute(contextVector, questionVector, optionVectors, count) {
const inputs = {
context: new ort.Tensor('float32', contextVector, [1, 256]),
question: new ort.Tensor('float32', questionVector, [1, 256]),
options: new ort.Tensor('float32', optionVectors, [1, count, 256]),
};
const core = await model.routers[0].run(inputs);
const natural = await model.routers[1].run(inputs);
const mix = (left, right) => Array.from(left, (value, index) => 0.4 * value + 0.6 * right[index]);
const questionLogits = mix(core.question_logits.data, natural.question_logits.data);
const optionLogits = mix(core.option_logits.data, natural.option_logits.data);
const weights = softmax(questionLogits);
const optionWeights = Array.from({length: count}, (_, index) =>
softmax(optionLogits.slice(index * 3, index * 3 + 3)));
const entropy = -weights.reduce((sum, value) => sum + value * Math.log(Math.max(value, 1e-12)), 0) / Math.log(3);
const both = weights[2] >= 0.10 || entropy >= 0.40;
const mode = both ? 'joint' : (weights[0] >= weights[1] ? 'language' : 'sound');
return {mode, weights, optionWeights, entropy};
}
function publicRoute(route) {
const experts = route.mode === 'language' ? ['speech'] : route.mode === 'sound' ? ['nonverbal'] : ['speech', 'nonverbal'];
const value = {mode: route.mode, experts, method: 'learned_sparse_router_ensemble_v1', entropy: Number(route.entropy.toFixed(4)),
weights: Object.fromEntries(['speech', 'nonverbal', 'joint'].map((name, index) => [name, Number(route.weights[index].toFixed(4))]))};
if (route.mode === 'joint') value.fusion = 'learned_option_weighted_logit_fusion_v1';
return value;
}
function typedAnswer(question, pairs, probabilities) {
const total = probabilities.reduce((sum, value) => sum + value, 0);
const ps = probabilities.map(value => value / total);
const entropy = -ps.reduce((sum, value) => sum + (value ? value * Math.log(value) : 0), 0) / Math.log(ps.length);
const answer = {type: question.type, probabilities: Object.fromEntries(pairs.map(([key], index) => [key, ps[index]])),
confidence: Math.max(0, Math.min(1, 1 - entropy)), confidence_kind: 'normalized_entropy_not_calibrated'};
if (question.type === 'noul') answer.noul = ps[1];
else if (question.type === 'choice') answer.choice = pairs[ps.indexOf(Math.max(...ps))][0];
else {answer.score = ps.reduce((sum, value, index) => sum + index * value, 0); answer.legend = Object.fromEntries(pairs);}
return answer;
}
async function textVector(value, warnings) {
if (textCache.has(value)) {
if (truncatedTexts.has(value)) warnings.add('長い文はモデルの上限128トークンに切り詰めました。');
return textCache.get(value);
}
const tokenized = model.tokenizer.encode(value, {add_special_tokens: true});
let tokens = Array.from(tokenized.ids);
if (tokens.length > 128) {truncatedTexts.add(value); tokens = [...tokens.slice(0, 127), 2]; warnings.add('長い文はモデルの上限128トークンに切り詰めました。');}
const ids = new BigInt64Array(128); ids.fill(3n);
const mask = new BigInt64Array(128);
tokens.forEach((token, index) => {ids[index] = BigInt(token); mask[index] = 1n;});
const output = await model.text.run({input_ids: new ort.Tensor('int64', ids, [1, 128]), attention_mask: new ort.Tensor('int64', mask, [1, 128])});
const vector = Float32Array.from(output.features.data); textCache.set(value, vector);
if (textCache.size > 512) {const key = textCache.keys().next().value; textCache.delete(key); truncatedTexts.delete(key);}
return vector;
}
function qwenWindows(samples) {
const width = 80000, hop = 40000;
const last = Math.max(0, samples.length - width); const starts = [];
for (let index = 0; index <= last; index += hop) starts.push(index);
if (!starts.length || starts.at(-1) !== last) starts.push(last);
return starts.map(start => samples.slice(start, Math.min(start + width, samples.length)));
}
async function qwenFeatures(samples) {
const language = await loadLanguage();
const windows = qwenWindows(samples); const features = new Float32Array(windows.length * 2048);
for (let index = 0; index < windows.length; index += 1) {
progress(`端末内で発話を分析中 ${index + 1}/${windows.length}`);
const extracted = await model.qwenExtractor(windows[index], {max_length: 80000});
const mel = Float32Array.from(extracted.input_features.data);
const output = await language.audio.run({input_features: new ort.Tensor('float32', mel, [128, 500])});
features.set(output.pooled_features.data, index * 2048);
}
return {features, count: windows.length};
}
function clapStarts(length) {
const count = Math.min(3, Math.max(1, Math.ceil(length / 480000)));
const last = Math.max(0, length - 480000);
if (count === 1) return [0];
return Array.from({length: count}, (_, index) => Math.trunc(index * last / (count - 1)));
}
async function clapScores(samples48k) {
const sound = await loadSound();
const starts = clapStarts(samples48k.length); const embeddings = [];
for (let index = 0; index < starts.length; index += 1) {
progress(`端末内で音を分析中 ${index + 1}/${starts.length}`);
const wave = samples48k.slice(starts[index], Math.min(starts[index] + 480000, samples48k.length));
const extracted = await model.clapExtractor(wave);
const mel = Float32Array.from(extracted.input_features.data);
const output = await sound.audio.run({input_features: new ort.Tensor('float32', mel, [1, 1, 1001, 64])});
embeddings.push(Float32Array.from(output.audio_embeds.data));
}
const feature = new Float32Array(1024);
for (let dimension = 0; dimension < 512; dimension += 1) {
let sum = 0, maximum = -Infinity;
for (const embedding of embeddings) {sum += embedding[dimension]; maximum = Math.max(maximum, embedding[dimension]);}
feature[dimension] = sum / embeddings.length; feature[512 + dimension] = maximum;
}
const output = await sound.heads.run({features: new ort.Tensor('float32', feature, [1, 1024])});
const sigmoid = values => Array.from(values, value => 1 / (1 + Math.exp(-value)));
const broadValues = sigmoid(output.broad_logits.data);
const animalValues = sigmoid(output.animal_logits.data);
return {broad: Object.fromEntries(['animal', 'nature', 'music', 'environment', 'dog', 'cat', 'bird', 'insect']
.map((label, index) => [label, broadValues[index]])),
animal8: Object.fromEntries(ANIMAL8.map((label, index) => [label, animalValues[index]])),
windows: starts.length};
}
function allSoundLabels(text) { return [...labelsIn(text, BROAD), ...labelsIn(text, ANIMAL8)]; }
function labelScore(label, scores) {
const value = Number(scores[ANIMAL8.includes(label) ? 'animal8' : 'broad'][label]);
if (!Number.isFinite(value)) throw new Error('音の判定値が不正です。');
return Math.max(0, Math.min(1, value));
}
function combineProbabilities(values, operator) {
if (!values.length) throw new Error('対応する音の種類を質問に指定してください。');
return operator === 'or' ? 1 - values.reduce((product, value) => product * (1 - value), 1)
: values.reduce((product, value) => product * value, 1);
}
function soundProbability(text, labels, scores, operator) {
const value = combineProbabilities(labels.map(label => labelScore(label, scores)), operator);
return isNegative(text) ? 1 - value : value;
}
function poissonBinomial(values) {
let distribution = [1];
for (const value of values) {
const next = new Array(distribution.length + 1).fill(0);
distribution.forEach((probability, count) => {
next[count] += probability * (1 - value); next[count + 1] += probability * value;
});
distribution = next;
}
return distribution;
}
function soundLikelihoods(question, pairs, scores) {
const instructionLabels = allSoundLabels(question.instructions);
const optionLabels = pairs.map(([, description]) => allSoundLabels(description));
if (question.type === 'score' && instructionLabels.length && pairs.length === instructionLabels.length + 1) {
return poissonBinomial(instructionLabels.map(label => labelScore(label, scores)));
}
const values = pairs.map(([, description], index) => optionLabels[index].length
? soundProbability(description, optionLabels[index], scores, operatorFor(description)) : null);
if (values.some(value => value !== null)) return values.map(value => value === null ? 1 : value);
const labels = instructionLabels.length ? instructionLabels
: allSoundLabels([question.instructions, ...pairs.map(([, value]) => value)].join('\n'));
if (!labels.length) throw new Error('質問に対応する音の種類を指定してください。');
const probability = soundProbability(question.instructions, labels, scores, operatorFor(question.instructions));
if (pairs.length === 2) return [1 - probability, probability];
if (question.type === 'score') {
const maximum = pairs.length - 1;
return pairs.map((_, index) => (1 - probability) * (1 - index / maximum) + probability * (index / maximum));
}
throw new Error('音のChoiceでは、各選択肢が表す音を説明してください。');
}
function soundAnswer(question, pairs, scores) {
if (question.type === 'noul') {
const labels = allSoundLabels(question.instructions);
const fallback = labels.length ? labels : allSoundLabels([question.instructions, ...pairs.map(([, value]) => value)].join('\n'));
const value = soundProbability(question.instructions, fallback, scores, operatorFor(question.instructions));
return typedAnswer(question, pairs, [1 - value, value]);
}
return typedAnswer(question, pairs, soundLikelihoods(question, pairs, scores));
}
function fuseAnswer(question, pairs, languageAnswer, scores, route) {
const language = pairs.map(([key]) => Number(languageAnswer.probabilities[key]));
const acoustic = soundLikelihoods(question, pairs, scores);
const combined = pairs.map((_, index) => {
const [speechWeight, nonverbalWeight, jointWeight] = route.optionWeights[index];
const languageWeight = speechWeight + 0.5 * jointWeight;
const soundWeight = nonverbalWeight + 0.5 * jointWeight;
return languageWeight * Math.log(Math.max(language[index], 1e-8))
+ soundWeight * Math.log(Math.max(acoustic[index], 1e-8));
});
const answer = typedAnswer(question, pairs, softmax(combined));
answer.fusion = {method: 'learned_option_weighted_logit_fusion_v1'};
return answer;
}
async function analyze(samples, samples48k, context, questions) {
if (!(samples instanceof Float32Array)) samples = new Float32Array(samples);
if (!(samples48k instanceof Float32Array)) samples48k = new Float32Array(samples48k || []);
if (samples.length < 4000 || samples.length > 480000 || samples.some(value => !Number.isFinite(value))) throw new Error('音声は0.25秒〜30秒の範囲で入力してください。');
if (samples48k.length < 12000 || samples48k.length > 1440000 || samples48k.some(value => !Number.isFinite(value))) throw new Error('音声の端末内変換に失敗しました。音声を選び直してください。');
if (typeof context !== 'string' || context.length > 2000) throw new Error('文脈は2000文字以内にしてください。');
if (!questions || typeof questions !== 'object' || Array.isArray(questions)) throw new Error('質問セットにはJSONオブジェクトを指定してください。');
const entries = Object.entries(questions);
if (!entries.length || entries.length > 16) throw new Error('質問数は1〜16個にしてください。');
const pairs = entries.map(([, question]) => optionsFor(question));
await load();
const started = performance.now(); const warnings = new Set(); const answers = Object.create(null);
const contextVector = await textVector(context, warnings);
const questionVectors = []; const optionVectorsByQuestion = []; const routes = [];
for (let index = 0; index < entries.length; index += 1) {
const [, question] = entries[index]; const choices = pairs[index];
const questionVector = await textVector(question.instructions, warnings);
const optionVectors = new Float32Array(choices.length * 256);
for (let option = 0; option < choices.length; option += 1) {
optionVectors.set(await textVector(choices[option][1], warnings), option * 256);
}
questionVectors.push(questionVector); optionVectorsByQuestion.push(optionVectors);
routes.push(await learnedRoute(contextVector, questionVector, optionVectors, choices.length));
}
let qwenCount = 0, qwenMs = 0;
if (routes.some(route => route.mode === 'language' || route.mode === 'joint')) {
const qwenStarted = performance.now();
const audio = await qwenFeatures(samples); qwenCount = audio.count;
for (let index = 0; index < entries.length; index += 1) {
if (routes[index].mode === 'sound') continue;
const [name, question] = entries[index]; const choices = pairs[index];
progress(`質問を判定中 ${index + 1}/${entries.length}`);
const output = await model.head.run({
audio: new ort.Tensor('float32', audio.features, [1, audio.count, 2048]),
audio_mask: new ort.Tensor('bool', new Uint8Array(audio.count).fill(1), [1, audio.count]),
context: new ort.Tensor('float32', contextVector, [1, 256]),
question: new ort.Tensor('float32', questionVectors[index], [1, 256]),
options: new ort.Tensor('float32', optionVectorsByQuestion[index], [1, choices.length, 256]),
option_mask: new ort.Tensor('bool', new Uint8Array(choices.length).fill(1), [1, choices.length]),
is_noul: new ort.Tensor('bool', new Uint8Array([question.type === 'noul' ? 1 : 0]), [1]),
});
const logits = Array.from(output.logits.data); const maximum = Math.max(...logits);
const exponentials = logits.map(value => Math.exp(value - maximum)); const total = exponentials.reduce((sum, value) => sum + value, 0);
answers[name] = typedAnswer(question, choices, exponentials.map(value => value / total));
}
qwenMs = performance.now() - qwenStarted;
}
let clapWindows = 0, clapMs = 0;
if (routes.some(route => route.mode === 'sound' || route.mode === 'joint')) {
const clapStarted = performance.now(); const scores = await clapScores(samples48k); clapWindows = scores.windows;
for (let index = 0; index < entries.length; index += 1) {
const route = routes[index]; if (route.mode === 'language') continue;
const [name, question] = entries[index];
answers[name] = route.mode === 'joint'
? fuseAnswer(question, pairs[index], answers[name], scores, route)
: soundAnswer(question, pairs[index], scores);
}
clapMs = performance.now() - clapStarted;
}
return {model: 'mocovoice-audio-jev-edge-qwen-noulmatch-clap-learned-router-v6', execution: {...status(), audio_sent_to_server: false}, answers,
evidence: {audio_duration_ms: samples.length / 16, qwen_windows: qwenCount, clap_windows: clapWindows,
shared_audio_encoders: ['Qwen/Qwen3-ASR-0.6B', 'laion/clap-htsat-unfused'], audio_reused_across_questions: true,
question_routing: Object.fromEntries(entries.map(([name], index) => [name, publicRoute(routes[index])]))},
latency_ms: {qwen: qwenMs, clap: clapMs, total: performance.now() - started}, warnings: [...warnings],
scope: '研究用試作。未知タスクへの正確さと確率校正は未検証。'};
}
let queue = Promise.resolve();
onmessage = ({data}) => {
queue = queue.then(async () => {
currentId = data.id;
try {
const result = data.type === 'load' ? await load(data.backend) : await analyze(data.samples, data.samples48k, data.context, data.questions);
postMessage({id: data.id, result});
} catch (error) {postMessage({id: data.id, error: error.message || String(error)});}
});
};