Spaces:
Runtime error
Runtime error
Download inference-worker.js from mocomoco-inc/AudioDecisionModel: direct link, hf CLI and curl.
- Browser
- Download file 25.4 kB
-
https://huggingface.co/spaces/mocomoco-inc/AudioDecisionModel/resolve/main/inference-worker.js
- Command line
-
hf download hf://spaces/mocomoco-inc/AudioDecisionModel/inference-worker.js
-
curl -L -o inference-worker.js https://huggingface.co/spaces/mocomoco-inc/AudioDecisionModel/resolve/main/inference-worker.js
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)});} | |
| }); | |
| }; | |