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();
});