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 ''; } 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 += `${escapeHtml(value)}`; 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(); });