Spaces:
Running
Running
Download ui.js from mocomoco-inc/AudioDecisionModel: direct link, hf CLI and curl.
- Browser
- Download file 37.3 kB
-
https://huggingface.co/spaces/mocomoco-inc/AudioDecisionModel/resolve/main/ui.js
- Command line
-
hf download hf://spaces/mocomoco-inc/AudioDecisionModel/ui.js
-
curl -L -o ui.js https://huggingface.co/spaces/mocomoco-inc/AudioDecisionModel/resolve/main/ui.js
37.3 kB
| import {createAudioPlayer} from './audio-player.js'; | |
| import {createAudioRecorder} from './audio-recorder.js'; | |
| const $ = (id) => document.getElementById(id); | |
| const MAX_SECONDS = 30; | |
| const MAX_QUESTIONS = 16; | |
| const MAX_FILE_BYTES = 100 * 1024 * 1024; | |
| let model = null; | |
| let audioSamples = null; | |
| let audioSamples48k = null; | |
| let audioName = "audio.wav"; | |
| let audioUrl = null; | |
| let audioSelectionVersion = 0; | |
| let latestResult = null; | |
| let inputRevision = 0; | |
| let formDraftError = ""; | |
| let player; | |
| let serverReady = false; | |
| let edgeIntroShown = false; | |
| let exampleRecords = []; | |
| let selectedExampleId = null; | |
| async function dismissStartupScreen() { | |
| const screen = $('startup-screen'); | |
| if (!screen) return; | |
| const fonts = document.fonts | |
| ? Promise.all([ | |
| document.fonts.load('400 16px "LINE Seed JP"'), | |
| document.fonts.load('700 16px "LINE Seed JP"'), | |
| document.fonts.ready, | |
| ]).then(() => true, () => false) | |
| : Promise.resolve(false); | |
| const fontTimeout = new Promise(resolve => setTimeout(() => resolve(false), 3000)); | |
| await Promise.race([fonts, fontTimeout]); | |
| await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); | |
| screen.classList.add('fonts-ready'); | |
| await new Promise(resolve => setTimeout(resolve, 1200)); | |
| await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); | |
| screen.classList.add('is-ready'); | |
| const remove = () => screen.remove(); | |
| screen.addEventListener('transitionend', remove, {once: true}); | |
| setTimeout(remove, 350); | |
| } | |
| dismissStartupScreen(); | |
| const presets = { | |
| identity: { | |
| context: "店員: ご希望の予約を教えてください。", | |
| questions: { "intent": { type: "choice", instructions: "文脈を踏まえると、この発話の目的は何ですか。", criteria: { reservation: "予約を新しく申し込む", change: "既存の予約や注文の内容を変更する", cancel: "既存の予約や注文を取り消す", order: "品物や飲食物を注文する", none: "予約・注文・変更・取り消しのいずれでもない" } } } | |
| }, | |
| service: { | |
| context: "この録音に音楽、動物音、自然音、または環境音が含まれるか確認してください。", | |
| questions: { "music_present": { type: "noul", instructions: "音声に歌や楽器を含む音楽が含まれますか。", criteria: { false: "歌や楽器を含む音楽は含まれない。", true: "歌や楽器を含む音楽が含まれる。" } }, "animal_present": { type: "noul", instructions: "音声に動物の鳴き声が含まれますか。", criteria: { false: "動物音は含まれない。", true: "動物音が含まれる。" } }, "nature_present": { type: "noul", instructions: "音声に雨、風、水流などの自然音が含まれますか。", criteria: { false: "自然音は含まれない。", true: "自然音が含まれる。" } }, "environment_present": { type: "noul", instructions: "音声に交通、機械、雑踏などの環境音が含まれますか。", criteria: { false: "環境音は含まれない。", true: "環境音が含まれる。" } } } | |
| }, | |
| classification: { | |
| context: "動物音・自然音・音楽・環境音の4カテゴリに注目してください。", | |
| questions: { "sound_variety": { type: "score", instructions: "動物音・自然音・音楽・環境音の4カテゴリのうち、存在するカテゴリの数を段階で評価してください。", criteria: ["対象の4カテゴリはどれも存在しない。", "対象のうち1カテゴリだけ存在する。", "対象のうち2カテゴリが存在する。", "対象のうち3カテゴリが存在する。", "対象の4カテゴリすべてが存在する。"] } } | |
| } | |
| }; | |
| presets.turntaking = {"context": "", "questions": {"speech_act": {"type": "choice", "instructions": "この発話の会話上の働きは何ですか。", "criteria": {"backchannel": "相手の話を聞いていることを示す相づち", "confirm": "確認への同意または承認", "request": "新しい依頼や要求", "correct": "前の内容の訂正や変更", "cancel": "依頼や予定の取り消し", "other": "これらに該当しない発話"}}}}; | |
| presets.animals = {"context": "", "questions": {"animal_type": {"criteria": {"dog": "犬の音", "cat": "猫の音", "bird": "鳥の音", "insect": "昆虫の音", "chicken_and_rooster": "ニワトリ・雄鶏の音", "crow": "カラスの音", "frog": "カエルの音", "cricket": "コオロギの音"}, "instructions": "この録音で聞こえる主な動物の音を選んでください。", "type": "choice"}}}; | |
| presets.language = {"context": "", "questions": {"language": {"type": "choice", "instructions": "この音声で主に使われている言語を選んでください。", "criteria": {"ja": "主に日本語で話している", "es": "主にスペイン語で話している", "en": "主に英語で話している", "ko": "主に韓国語で話している", "zh": "主に中国語で話している"}}}}; | |
| function showError(id, message = "") { $(id).textContent = message; } | |
| function pretty(value) { return JSON.stringify(value, null, 2); } | |
| function defaultCriteria(type) { return type === "noul" ? { false: "該当しません", true: "該当します" } : type === "choice" ? { "選択肢A": "説明", "選択肢B": "説明" } : ["低い", "高い"]; } | |
| function status(message, error = false) { const node = $("analysis-status"); node.textContent = message; node.style.color = error ? "var(--error, #b42318)" : ""; } | |
| function clearResult() { inputRevision++; latestResult = null; $("result-json").textContent = "{}"; $("summary").classList.add("is-empty"); $("summary").textContent = "解析結果がここに表示されます。"; $("inference-time").hidden = true; updateAnalyzeButton(); } | |
| function renderInferenceTime(result) { | |
| $("inference-time").hidden = true; | |
| } | |
| function getContext() { const value = $("context").value; if ([...value].length > 2000) throw new Error("文脈・補足は2000文字以内にしてください。"); return value; } | |
| function isServerMode() { return $("execution-mode").value === "server"; } | |
| function updateAnalyzeButton() { | |
| const button = $('analyze'); | |
| if (button.getAttribute('aria-busy') === 'true') return; | |
| const needsEdgeModel = !isServerMode() && !model; | |
| button.textContent = needsEdgeModel ? 'モデルを読み込む' : '実行する'; | |
| button.disabled = false; | |
| } | |
| let modeViewTransition; | |
| function needsEdgeIntro() { | |
| if (edgeIntroShown) return false; | |
| try { return localStorage.getItem('audio-jev-edge-intro-v2') !== 'seen'; } catch { return true; } | |
| } | |
| function updateExecutionMode() { | |
| const nextMode = isServerMode() ? 'server' : 'edge'; | |
| const openingEdgeIntro = nextMode === 'edge' && needsEdgeIntro(); | |
| if (!document.body.dataset.execution || document.body.dataset.execution === nextMode || openingEdgeIntro || matchMedia('(prefers-reduced-motion: reduce)').matches || !document.startViewTransition) { | |
| if (openingEdgeIntro) modeViewTransition?.skipTransition(); | |
| applyExecutionMode(); | |
| return; | |
| } | |
| modeViewTransition?.skipTransition(); | |
| document.documentElement.classList.add('changing-mode'); | |
| document.documentElement.dataset.modeDirection = nextMode; | |
| const transition = document.startViewTransition(() => applyExecutionMode()); | |
| modeViewTransition = transition; | |
| transition.finished.catch(() => {}).finally(() => { | |
| if (modeViewTransition === transition) { | |
| modeViewTransition = null; | |
| document.documentElement.classList.remove('changing-mode'); | |
| delete document.documentElement.dataset.modeDirection; | |
| } | |
| }); | |
| } | |
| function applyExecutionMode() { | |
| const server = isServerMode(); | |
| document.querySelectorAll('[name="execution"]').forEach(input => { input.checked = input.value === $("execution-mode").value; }); | |
| const title = "Audio Decision Model"; | |
| $("edge-mode-label").hidden = server; | |
| const nextMode = server ? 'server' : 'edge'; | |
| document.body.dataset.execution = nextMode; | |
| document.body.classList.toggle("edge-mode", !server); | |
| renderExamples(); | |
| player?.refreshTheme(); | |
| if (!server) showEdgeIntroOnce(); | |
| $('mode-announcement-text').textContent = server | |
| ? 'データをAIクラウドに送ってJevを実行します。' | |
| : 'データをクラウドに送らず、この端末内でJevを実行します。外部送信不要で、オフラインでも利用できます。初回のみモデルのダウンロードが必要です。'; | |
| $("product-name").textContent = title; | |
| document.title = `${title} by mocomoco inc.`; | |
| $("backend-wrap").hidden = true; | |
| $("execution-kicker").textContent = server ? "PRIVATE SERVER" : "ON-DEVICE MODEL"; | |
| $("model-title").textContent = server ? "クラウドの準備状況" : "Edge AIを準備"; | |
| clearResult(); | |
| if (server) checkServerStatus(); | |
| else if (!model) status('「モデルを読み込む」でEdge AIを準備できます。'); | |
| else status(audioSamples ? 'Edge AIで解析できます。' : '音声を選んでください。'); | |
| } | |
| function getQuestions() { | |
| let data; | |
| try { data = JSON.parse($("questions-json").value); } catch { throw new Error("質問JSONは有効なJSONにしてください。"); } | |
| if (!data || Array.isArray(data) || typeof data !== "object") throw new Error("質問JSONは質問名をキーにしたオブジェクトにしてください。"); | |
| const entries = Object.entries(data); | |
| if (!entries.length || entries.length > MAX_QUESTIONS) throw new Error(`質問は1〜${MAX_QUESTIONS}件にしてください。`); | |
| for (const [name, q] of entries) { | |
| if (!name.trim() || !q || !["noul", "choice", "score"].includes(q.type) || typeof q.instructions !== "string" || !q.instructions.trim()) throw new Error(`「${name}」のtypeとinstructionsを確認してください。`); | |
| if (q.type === "noul" && (!q.criteria || typeof q.criteria !== "object" || Array.isArray(q.criteria) || !("false" in q.criteria) || !("true" in q.criteria))) throw new Error(`「${name}」のNoul基準には false と true が必要です。`); | |
| if (q.type === "choice" && (!q.criteria || Array.isArray(q.criteria) || typeof q.criteria !== "object" || Object.keys(q.criteria).length < 2 || Object.keys(q.criteria).length > 32)) throw new Error(`「${name}」のChoice基準は2〜32件にしてください。`); | |
| if (q.type === "score" && (!Array.isArray(q.criteria) || q.criteria.length < 2 || q.criteria.length > 10)) throw new Error(`「${name}」のScore基準は2〜10件にしてください。`); | |
| } | |
| return data; | |
| } | |
| function questionOptionLabel(question, index) { | |
| const text = question.instructions.trim().replace(/\s+/g, " "); | |
| return `${index + 1}. ${text.length > 24 ? `${text.slice(0, 24)}…` : text}`; | |
| } | |
| function syncBuilderOptions(preferredName = "") { | |
| const select = $("existing-question"); | |
| const current = preferredName || select.value; | |
| const questions = getQuestions(); | |
| const entries = Object.entries(questions); | |
| select.replaceChildren(...entries.map(([, question], index) => new Option(questionOptionLabel(question, index), entries[index][0]))); | |
| select.value = questions[current] ? current : entries[0]?.[0] || ""; | |
| $("existing-question").closest("label").hidden = entries.length < 2; | |
| showError("question-error"); | |
| return questions; | |
| } | |
| function renderQuestions(questions, selectedName = "") { | |
| $("questions-json").value = pretty(questions); | |
| syncBuilderOptions(selectedName); | |
| } | |
| function showQuestionSource(label) { | |
| const select = $('preset'); | |
| let option = select.querySelector('option[value="current"]'); | |
| if (!option) { | |
| option = new Option(label, 'current'); | |
| option.disabled = true; | |
| select.add(option, 0); | |
| } | |
| option.textContent = label; | |
| select.value = 'current'; | |
| } | |
| function loadPreset(key) { | |
| const preset = presets[key]; | |
| if (!preset) return; | |
| $('preset').querySelector('option[value="current"]')?.remove(); | |
| $('preset').value = key; | |
| $("context").value = preset.context; | |
| renderQuestions(preset.questions); | |
| loadSelectedQuestion(); | |
| updateCount(); | |
| clearResult(); | |
| } | |
| async function decodeAudio(blob, name = "audio.wav", transcript = "", selectionVersion = ++audioSelectionVersion) { | |
| audioSamples = null; audioSamples48k = null; clearResult(); | |
| const bytes = await blob.arrayBuffer(); | |
| const context = new AudioContext(); | |
| try { | |
| const buffer = await context.decodeAudioData(bytes.slice(0)); | |
| if (buffer.duration > MAX_SECONDS) throw new Error(`音声は${MAX_SECONDS}秒以内にしてください(現在 ${buffer.duration.toFixed(1)}秒)。`); | |
| const samples = await resample(buffer, 16000); | |
| const samples48k = await resample(buffer, 48000); | |
| if (selectionVersion !== audioSelectionVersion) return false; | |
| audioSamples = samples; audioSamples48k = samples48k; | |
| $("sample-transcript").textContent = transcript; $("sample-transcript").hidden = false; | |
| audioName = name.replace(/\.[^.]+$/, "") + ".wav"; | |
| if (audioUrl) URL.revokeObjectURL(audioUrl); audioUrl = URL.createObjectURL(blob); $("audio-preview").src = audioUrl; $("audio-preview").hidden = true; player?.setSamples(audioSamples); | |
| $("audio-status").textContent = `${buffer.duration.toFixed(1)}秒を16 kHzへ変換しました`; | |
| clearResult(); status("音声の準備ができました。"); | |
| return true; | |
| } finally { await context.close(); } | |
| } | |
| async function resample(input, sampleRate) { | |
| const frames = Math.ceil(input.duration * sampleRate); | |
| const offline = new OfflineAudioContext(1, frames, sampleRate); | |
| const mono = offline.createBuffer(1, input.length, input.sampleRate); | |
| const target = mono.getChannelData(0); | |
| for (let channel = 0; channel < input.numberOfChannels; channel += 1) { const source = input.getChannelData(channel); for (let i = 0; i < source.length; i += 1) target[i] += source[i] / input.numberOfChannels; } | |
| const node = offline.createBufferSource(); node.buffer = mono; node.connect(offline.destination); node.start(); | |
| return (await offline.startRendering()).getChannelData(0).slice(); | |
| } | |
| function readableResult(result) { | |
| const questions = getQuestions(); | |
| const fieldLabels = { | |
| speech_act: '発話の役割', intent: '用件', turn_complete: 'ターン状態', | |
| music_present: '音楽', animal_present: '動物音', nature_present: '自然音', | |
| environment_present: '環境音', sound_variety: '音の種類', animal_type: '動物の種類', language: '話されている言語' | |
| }; | |
| const valueLabels = { | |
| speech_act: { backchannel: '相づち', confirm: '同意・承認', request: '依頼', correct: '訂正・変更', cancel: '取り消し', other: 'その他' }, | |
| intent: { reservation: '予約', change: '変更', cancel: '取り消し', order: '注文', none: '該当なし' }, | |
| animal_type: { dog: '犬', cat: '猫', bird: '鳥', insect: '昆虫', chicken_and_rooster: 'ニワトリ・雄鶏', crow: 'カラス', frog: 'カエル', cricket: 'コオロギ' }, | |
| language: { ja: '日本語', es: 'スペイン語', en: '英語', ko: '韓国語', zh: '中国語' } | |
| }; | |
| return '<ul>' + Object.entries(result.answers || {}).map(([name, item]) => { | |
| const question = questions[name] || {}; | |
| const heading = fieldLabels[name] || name.replaceAll('_', ' '); | |
| if (item.type === 'choice') { | |
| const value = valueLabels[name]?.[item.choice] || item.choice; | |
| const description = question.criteria?.[item.choice]; | |
| return `<li><span class="result-field">${escapeHtml(heading)}</span><div class="result-main">${escapeHtml(String(value))}</div>${description && description !== value ? `<span class="result-description">${escapeHtml(description)}</span>` : ''}</li>`; | |
| } | |
| if (item.type === 'noul') { | |
| const positive = Math.max(0, Math.min(1, Number(item.noul) || 0)); | |
| const negative = 1 - positive; | |
| const isTurn = name === 'turn_complete'; | |
| const isDetection = ['music_present', 'animal_present', 'nature_present', 'environment_present'].includes(name); | |
| const main = positive >= .6 | |
| ? (isTurn ? '話し終えています' : isDetection ? '含まれています' : 'はい') | |
| : positive <= .4 | |
| ? (isTurn ? 'まだ続きそうです' : isDetection ? '含まれていません' : 'いいえ') | |
| : '判断が分かれています'; | |
| const negativeLabel = isTurn ? '継続' : isDetection ? 'なし' : 'いいえ'; | |
| const positiveLabel = isTurn ? '完了' : isDetection ? 'あり' : 'はい'; | |
| const aria = `${negativeLabel} ${(negative * 100).toFixed(1)}%、${positiveLabel} ${(positive * 100).toFixed(1)}%`; | |
| return `<li><span class="result-field">${escapeHtml(heading)}</span><div class="result-main">${escapeHtml(main)}</div><div class="decision-scale" aria-label="${escapeHtml(aria)}"><div class="decision-track"><span class="decision-fill" style="width:${(positive * 100).toFixed(1)}%"></span></div><div class="decision-labels"><span>${escapeHtml(negativeLabel)} ${(negative * 100).toFixed(1)}%</span><span>${escapeHtml(positiveLabel)} ${(positive * 100).toFixed(1)}%</span></div></div></li>`; | |
| } | |
| const score = Number(item.score); | |
| const max = Math.max(1, (question.criteria?.length || Object.keys(item.legend || {}).length) - 1); | |
| const note = item.legend?.[Math.round(score)] || ''; | |
| return `<li><span class="result-field">${escapeHtml(heading)}</span><div class="result-main">${escapeHtml(`${score.toFixed(2)} / ${max}`)}</div>${note ? `<span class="result-description">${escapeHtml(note)}</span>` : ''}</li>`; | |
| }).join('') + '</ul>'; | |
| } | |
| function renderJson(result) { | |
| const answers = Object.fromEntries(Object.entries(result.answers || {}).map(([name, item]) => { | |
| const value = item.type === 'choice' ? item.choice : item.type === 'noul' ? item.noul : item.score; | |
| return [name, typeof value === 'number' ? Number(value.toFixed(4)) : value]; | |
| })); | |
| $('result-json').innerHTML = highlightJson(pretty(answers)); | |
| } | |
| function highlightJson(source) { | |
| let html = '', offset = 0; | |
| const tokens = /"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b/g; | |
| for (const match of source.matchAll(tokens)) { | |
| html += escapeHtml(source.slice(offset, match.index)); | |
| const value = match[0]; | |
| const kind = value.startsWith('"') ? (/^\s*:/.test(source.slice(match.index + value.length)) ? 'key' : 'string') : /^(true|false|null)$/.test(value) ? 'literal' : 'number'; | |
| html += `<span class="json-${kind}">${escapeHtml(value)}</span>`; offset = match.index + value.length; | |
| } | |
| return html + escapeHtml(source.slice(offset)); | |
| } | |
| function escapeHtml(text) { const node = document.createElement("span"); node.textContent = text; return node.innerHTML; } | |
| async function loadModel() { | |
| if (model) return; | |
| const api = await import('./inference.js?v=20260921-load-progress-v1'); | |
| const ready = await api.loadModel({backend:'wasm', onProgress: info => { | |
| status(typeof info === 'string' ? info : (info?.message || 'モデルをダウンロード中…')); | |
| }}); | |
| if (!ready?.language_ready || !ready?.sound_ready) throw new Error('モデルの読み込みが完了していません。もう一度お試しください。'); | |
| model = api; | |
| } | |
| async function prepareEdgeModel() { | |
| const button = $('analyze'); | |
| try { | |
| button.disabled = true; button.classList.add('is-busy'); button.setAttribute('aria-busy', 'true'); | |
| $('execution-mode').disabled = true; $('execution-toggle').disabled = true; | |
| button.textContent = 'モデルを読み込み中…'; status('モデルをダウンロード中…'); | |
| await loadModel(); | |
| status(audioSamples ? 'モデルを読み込みました。「実行する」で解析できます。' : 'モデルを読み込みました。音声を選んでください。'); | |
| } catch (error) { | |
| status(error.message || 'モデルを読み込めませんでした。', true); | |
| } finally { | |
| button.classList.remove('is-busy'); button.removeAttribute('aria-busy'); | |
| $('execution-mode').disabled = false; $('execution-toggle').disabled = false; updateAnalyzeButton(); | |
| } | |
| } | |
| async function handleAnalyzeAction() { | |
| if (!isServerMode() && !model) { await prepareEdgeModel(); return; } | |
| await analyze(); | |
| } | |
| async function checkServerStatus() { | |
| try { | |
| const response = await fetch("/api/status", { headers: { Accept: "application/json" } }); | |
| if (!response.ok) throw new Error("クラウドの準備状況を取得できませんでした。"); | |
| const info = await response.json(); | |
| serverReady = info.ready === true || info.readiness === "ready"; | |
| status(serverReady ? "" : "クラウドは準備中です。少し待ってから解析してください。", !serverReady); | |
| } catch (error) { | |
| serverReady = false; | |
| status(error.message || "クラウドの準備状況を確認できませんでした。", true); | |
| } | |
| } | |
| async function analyze() { | |
| const button = $('analyze'); | |
| try { | |
| if (formDraftError) throw new Error(formDraftError); | |
| const questions = getQuestions(); const context = getContext(); showError('question-error'); | |
| if (!audioSamples) throw new Error('音声を選ぶか録音してください。'); | |
| const samples = audioSamples; const server = isServerMode(); const analyzedRevision = inputRevision; | |
| button.disabled = true; button.classList.add('is-busy'); button.setAttribute('aria-busy','true'); $('execution-mode').disabled = true; $('execution-toggle').disabled = true; | |
| if (server && !serverReady) await checkServerStatus(); | |
| if (server && !serverReady) throw new Error('クラウドは準備中です。少し待って再度お試しください。'); | |
| if (!server && !model) throw new Error('先にモデルを読み込んでください。'); | |
| if (analyzedRevision !== inputRevision) { status('入力が変わりました。もう一度解析してください。'); return; } | |
| button.textContent = '実行中…'; status(server ? 'クラウドでJevを実行しています。' : 'この端末でJevを実行しています。'); | |
| const result = server ? await analyzeOnServer(samples, context, questions) : await model.analyze(samples, audioSamples48k, context, questions, {onProgress: info => status(typeof info === 'string' ? info : info.message)}); | |
| if (analyzedRevision !== inputRevision) { status('入力が変わりました。もう一度解析してください。'); return; } | |
| latestResult = result; renderJson(result); renderInferenceTime(result); $('summary').classList.remove('is-empty'); $('summary').innerHTML = readableResult(result); status('解析が完了しました。'); | |
| } catch (error) { status(error.message || '解析に失敗しました。', true); } | |
| finally { button.classList.remove('is-busy'); button.removeAttribute('aria-busy'); $('execution-mode').disabled = false; $('execution-toggle').disabled = false; updateAnalyzeButton(); } | |
| } | |
| async function analyzeOnServer(samples, context, questions) { | |
| const response = await fetch("/api/analyze", { | |
| method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, | |
| body: JSON.stringify({ samples: Array.from(samples), context, questions }) | |
| }); | |
| if (!response.ok) { | |
| let message = "クラウドで解析できませんでした。"; | |
| try { message = (await response.json()).detail || message; } catch { /* Keep a clear client-facing message. */ } | |
| throw new Error(message); | |
| } | |
| return response.json(); | |
| } | |
| function updateCount() { $("context-count").textContent = [...$("context").value].length; } | |
| function selectSample(button = null, record = null) { | |
| selectedExampleId = record?.id || null; | |
| document.querySelectorAll('#examples button').forEach(node => node.setAttribute('aria-pressed', String(node === button))); | |
| } | |
| function renderExamples() { | |
| const box = $('examples'); | |
| if (!box) return; | |
| box.replaceChildren(); | |
| const mode = isServerMode() ? 'server' : 'edge'; | |
| exampleRecords | |
| .filter(record => !Array.isArray(record.execution) || record.execution.includes(mode)) | |
| .forEach(record => { | |
| if (!record?.name || !record.audio || !record.questions) return; | |
| const button = document.createElement('button'); | |
| button.textContent = record.name.split('(')[0]; button.title = record.name; | |
| button.setAttribute('aria-pressed', String(record.id === selectedExampleId)); | |
| button.onclick = async () => { | |
| const version = ++audioSelectionVersion; | |
| selectSample(button, record); audioSamples = null; audioSamples48k = null; clearResult(); | |
| $('context').value = record.context || ''; | |
| renderQuestions(record.questions); loadSelectedQuestion(); updateCount(); | |
| showQuestionSource(`${button.textContent}サンプルの質問`); | |
| $('sample-transcript').textContent = record.text || ''; | |
| $('sample-transcript').hidden = false; | |
| $('audio-preview').pause(); | |
| $('audio-player').setAttribute('aria-busy', 'true'); | |
| try { | |
| const blob = await fetchSampleAudio(record.audio); | |
| if (version !== audioSelectionVersion) return; | |
| if (!await decodeAudio(blob, record.audio, record.text || '', version)) return; | |
| } catch (error) { | |
| if (version !== audioSelectionVersion) return; | |
| selectSample(); status(error.message || 'サンプル音声を読み込めませんでした。', true); | |
| } finally { | |
| if (version === audioSelectionVersion) $('audio-player').removeAttribute('aria-busy'); | |
| } | |
| }; | |
| box.append(button); | |
| fetchSampleAudio(record.audio).catch(() => {}); | |
| }); | |
| $('examples-section').hidden = !box.children.length; | |
| } | |
| async function loadExamples() { | |
| try { | |
| const response = await fetch('./examples/catalog.json'); if (!response.ok) return; | |
| const records = await response.json(); if (!Array.isArray(records)) return; | |
| exampleRecords = records; | |
| renderExamples(); | |
| } catch { status('サンプル一覧を読み込めませんでした。', true); } | |
| } | |
| const sampleAudioCache = new Map(); | |
| function fetchSampleAudio(path) { | |
| if (!sampleAudioCache.has(path)) { | |
| const promise = fetch(`./${path}`).then(response => { | |
| if (!response.ok) throw new Error('サンプル音声を読み込めませんでした。'); | |
| return response.blob(); | |
| }).catch(error => { sampleAudioCache.delete(path); throw error; }); | |
| sampleAudioCache.set(path, promise); | |
| } | |
| return sampleAudioCache.get(path); | |
| } | |
| function supportedAudioFile(file) { | |
| return file instanceof File && ( | |
| file.type.startsWith('audio/') || /\.(?:aac|flac|m4a|mp3|oga|ogg|opus|wav|webm)$/i.test(file.name) | |
| ); | |
| } | |
| async function useAudioFile(file) { | |
| if (!supportedAudioFile(file)) throw new Error('音声ファイルを選んでください。'); | |
| if (file.size > MAX_FILE_BYTES) throw new Error('ファイルサイズは100 MB以内にしてください。'); | |
| selectSample(); | |
| $('audio-player').removeAttribute('aria-busy'); | |
| status('音声ファイルを読み込んでいます…'); | |
| await decodeAudio(file, file.name); | |
| } | |
| $("audio-file").addEventListener('change', async event => { | |
| const file = event.target.files[0]; | |
| event.target.value = ''; | |
| if (!file) return; | |
| try { await useAudioFile(file); } | |
| catch (error) { status(error.message, true); } | |
| }); | |
| const recorderDropZone = $('recorder'); | |
| let recorderDragDepth = 0; | |
| function draggingFiles(event) { | |
| return Array.from(event.dataTransfer?.types || []).includes('Files'); | |
| } | |
| function clearRecorderDragState() { | |
| recorderDragDepth = 0; | |
| recorderDropZone.classList.remove('is-dragover'); | |
| } | |
| recorderDropZone.addEventListener('dragenter', event => { | |
| if (!draggingFiles(event)) return; | |
| event.preventDefault(); | |
| if (recorderDropZone.classList.contains('is-recording')) return; | |
| recorderDragDepth += 1; | |
| recorderDropZone.classList.add('is-dragover'); | |
| }); | |
| recorderDropZone.addEventListener('dragover', event => { | |
| if (!draggingFiles(event)) return; | |
| event.preventDefault(); | |
| event.dataTransfer.dropEffect = recorderDropZone.classList.contains('is-recording') ? 'none' : 'copy'; | |
| }); | |
| recorderDropZone.addEventListener('dragleave', event => { | |
| if (!draggingFiles(event)) return; | |
| recorderDragDepth = Math.max(0, recorderDragDepth - 1); | |
| if (!recorderDragDepth) recorderDropZone.classList.remove('is-dragover'); | |
| }); | |
| recorderDropZone.addEventListener('drop', async event => { | |
| if (!draggingFiles(event)) return; | |
| event.preventDefault(); | |
| clearRecorderDragState(); | |
| if (recorderDropZone.classList.contains('is-recording')) { | |
| status('録音を終了してから音声ファイルを選んでください。', true); | |
| return; | |
| } | |
| const file = Array.from(event.dataTransfer.files).find(supportedAudioFile); | |
| try { | |
| if (!file) throw new Error('音声ファイルを選んでください。'); | |
| await useAudioFile(file); | |
| } catch (error) { status(error.message, true); } | |
| }); | |
| window.addEventListener('dragend', clearRecorderDragState); | |
| window.addEventListener('drop', clearRecorderDragState); | |
| function criterionRow(value, key, type) { | |
| const row = document.createElement('div'); | |
| row.className = 'criterion-row'; | |
| if (key) row.dataset.key = key; | |
| const order = document.createElement('span'); | |
| order.className = 'criterion-order'; | |
| const input = document.createElement('input'); | |
| input.type = 'text'; | |
| input.value = value; | |
| input.placeholder = type === 'choice' ? '選択肢を入力' : 'この段階の基準を入力'; | |
| const remove = document.createElement('button'); | |
| remove.type = 'button'; | |
| remove.className = 'remove-criterion'; | |
| remove.dataset.removeCriterion = ''; | |
| remove.textContent = '×'; | |
| remove.setAttribute('aria-label', 'この項目を削除'); | |
| row.append(order, input, remove); | |
| return row; | |
| } | |
| function updateCriterionControls() { | |
| const type = $('question-type').value; | |
| const rows = [...$('criteria-items').children]; | |
| rows.forEach((row, index) => { | |
| row.querySelector('.criterion-order').textContent = String(index + 1); | |
| row.querySelector('input').setAttribute('aria-label', `${type === 'choice' ? '選択肢' : '評価段階'} ${index + 1}`); | |
| row.querySelector('.remove-criterion').disabled = rows.length <= 2; | |
| }); | |
| $('add-criterion').disabled = rows.length >= (type === 'choice' ? 32 : 10); | |
| } | |
| function renderCriteria(question) { | |
| const type = question.type; | |
| const panel = $('criteria-panel'); | |
| const items = $('criteria-items'); | |
| items.replaceChildren(); | |
| panel.hidden = type === 'noul'; | |
| if (type === 'noul') return; | |
| const choice = type === 'choice'; | |
| $('criteria-title').textContent = choice ? '選択肢' : '評価段階'; | |
| $('criteria-limit').textContent = choice ? '2〜32件' : '2〜10段階'; | |
| $('add-criterion').textContent = choice ? '+ 選択肢を追加' : '+ 評価段階を追加'; | |
| const criteria = question.criteria ?? defaultCriteria(type); | |
| const entries = choice ? Object.entries(criteria) : criteria.map((value, index) => [String(index), value]); | |
| entries.forEach(([key, value]) => items.append(criterionRow(String(value ?? ''), choice ? key : '', type))); | |
| updateCriterionControls(); | |
| } | |
| function readVisibleCriteria(type, questions, oldName) { | |
| if (type === 'noul') { | |
| const existing = questions[oldName]; | |
| return existing?.type === 'noul' ? existing.criteria : defaultCriteria('noul'); | |
| } | |
| const rows = [...$('criteria-items').children]; | |
| const values = rows.map(row => row.querySelector('input').value.trim()); | |
| if (values.some(value => !value)) throw new Error(type === 'choice' ? '選択肢を入力してください。' : 'すべての評価段階を入力してください。'); | |
| if (type === 'score') return values; | |
| return Object.fromEntries(rows.map((row, index) => [row.dataset.key || `option_${index + 1}`, values[index]])); | |
| } | |
| function loadSelectedQuestion() { | |
| try { | |
| const questions = getQuestions(); | |
| const name = $('existing-question').value; | |
| const question = questions[name]; | |
| if (!question) return; | |
| $('question-name').value = name; | |
| $('question-type').value = question.type; | |
| $('question-instructions').value = question.instructions; | |
| renderCriteria(question); | |
| formDraftError = ''; | |
| showError('question-error'); | |
| } catch (error) { | |
| formDraftError = error.message; | |
| showError('question-error', formDraftError); | |
| } | |
| } | |
| function applyFormDraft() { | |
| clearResult(); | |
| const previous = $('questions-json').value; | |
| try { | |
| const questions = getQuestions(); | |
| const oldName = $('existing-question').value; | |
| const name = $('question-name').value.trim(); | |
| const type = $('question-type').value; | |
| const instructions = $('question-instructions').value.trim(); | |
| if (!name) throw new Error('詳細設定の質問IDを入力してください。'); | |
| if (!instructions) throw new Error('質問文を入力してください。'); | |
| if (name !== oldName && Object.hasOwn(questions, name)) throw new Error('同じ質問IDがあります。'); | |
| const question = {type, instructions, criteria: readVisibleCriteria(type, questions, oldName)}; | |
| if (oldName && oldName !== name) delete questions[oldName]; | |
| questions[name] = question; | |
| $('questions-json').value = pretty(questions); | |
| getQuestions(); | |
| formDraftError = ''; | |
| syncBuilderOptions(name); | |
| showQuestionSource('編集中の質問'); | |
| showError('question-error'); | |
| } catch (error) { | |
| $('questions-json').value = previous; | |
| formDraftError = error.message; | |
| showError('question-error', formDraftError); | |
| } | |
| } | |
| $('question-builder').onsubmit = event => event.preventDefault(); | |
| $('preset').onchange = event => loadPreset(event.target.value); | |
| $('context').oninput = () => { updateCount(); clearResult(); }; | |
| $('execution-mode').onchange = updateExecutionMode; | |
| $('existing-question').onchange = loadSelectedQuestion; | |
| $('question-instructions').addEventListener('input', applyFormDraft); | |
| $('question-name').addEventListener('input', applyFormDraft); | |
| $('question-type').addEventListener('change', event => { | |
| renderCriteria({type: event.target.value, criteria: defaultCriteria(event.target.value)}); | |
| applyFormDraft(); | |
| }); | |
| $('criteria-items').addEventListener('input', event => { | |
| if (event.target.matches('input')) applyFormDraft(); | |
| }); | |
| $('criteria-items').addEventListener('click', event => { | |
| const button = event.target.closest('[data-remove-criterion]'); | |
| if (!button || button.disabled) return; | |
| button.closest('.criterion-row').remove(); | |
| updateCriterionControls(); | |
| applyFormDraft(); | |
| }); | |
| $('add-criterion').addEventListener('click', () => { | |
| const type = $('question-type').value; | |
| const rows = [...$('criteria-items').children]; | |
| const used = new Set(rows.map(row => row.dataset.key).filter(Boolean)); | |
| let number = rows.length + 1; | |
| while (used.has(`option_${number}`)) number += 1; | |
| const row = criterionRow('', type === 'choice' ? `option_${number}` : '', type); | |
| $('criteria-items').append(row); | |
| updateCriterionControls(); | |
| showQuestionSource('編集中の質問'); | |
| row.querySelector('input').focus(); | |
| formDraftError = type === 'choice' ? '選択肢を入力してください。' : 'すべての評価段階を入力してください。'; | |
| showError('question-error', formDraftError); | |
| }); | |
| $('questions-json').addEventListener('input', () => { | |
| clearResult(); | |
| showQuestionSource('編集中の質問'); | |
| try { | |
| syncBuilderOptions($('existing-question').value); | |
| loadSelectedQuestion(); | |
| } catch (error) { | |
| formDraftError = error.message; | |
| showError('question-error', formDraftError); | |
| } | |
| }); | |
| $("analyze").onclick = handleAnalyzeAction; | |
| loadPreset("identity"); updateExecutionMode(); loadExamples(); | |
| createAudioRecorder({button: $('record'), canvas: $('record-waveform'), timer: $('record-timer'), state: $('record-state'), onAudio: async blob => {await decodeAudio(blob, 'recording.webm'); status('録音できました。「実行する」で結果を確認できます。');}, onError: message => status(message, true)}); | |
| $("record").addEventListener("click", () => { if ($("record").getAttribute("aria-pressed") !== "true") { audioSelectionVersion++; selectSample(); $('audio-player').removeAttribute('aria-busy'); audioSamples = null; audioSamples48k = null; clearResult(); } }, {capture: true}); | |
| player = createAudioPlayer({audio:$('audio-preview'), container:$('audio-player'), canvas:$('player-waveform'), playButton:$('player-play'), seek:$('player-seek'), time:$('player-time'), speed:$('player-speed'), onError: message => status(message,true)}); | |
| for (const input of document.querySelectorAll('[name="execution"]')) { | |
| input.addEventListener('change', () => { | |
| if (!input.checked) return; | |
| $('execution-mode').value = input.value; | |
| updateExecutionMode(); | |
| }); | |
| } | |
| function showEdgeIntroOnce() { | |
| if (!needsEdgeIntro()) return; | |
| $('edge-intro').showModal(); | |
| edgeIntroShown = true; | |
| try { localStorage.setItem('audio-jev-edge-intro-v2', 'seen'); } catch {} | |
| } | |
| $('edge-intro-close').addEventListener('click', () => $('edge-intro').close()); | |
| $('edge-intro').addEventListener('click', event => { | |
| if (event.target !== $('edge-intro')) return; | |
| const rect = $('edge-intro').getBoundingClientRect(); | |
| if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) $('edge-intro').close(); | |
| }); | |