const state = { mediaStream: null, audioContext: null, source: null, processor: null, chunks: [], sampleRate: 44100, isRecording: false, pendingBlob: null, pendingUrl: null, models: [], project: null, history: [], objectUrls: [], nextId: 1, }; const els = { status: document.querySelector("#status"), messages: document.querySelector("#messages"), modelA: document.querySelector("#modelA"), modelB: document.querySelector("#modelB"), recordBtn: document.querySelector("#recordBtn"), recordText: document.querySelector("#recordText"), sendBtn: document.querySelector("#sendBtn"), preview: document.querySelector("#preview"), }; function setStatus(text) { els.status.textContent = text; } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function modelOptionText(model) { return `${model.kind.toUpperCase()} • ${model.title} • accuracy ${model.accuracyText} • macro F1 ${model.macroF1Text}`; } function currentSelectionText() { const modelA = state.models.find((item) => item.key === els.modelA.value); const modelB = state.models.find((item) => item.key === els.modelB.value); return [modelA, modelB].filter(Boolean).map(modelOptionText).join("
"); } function appendAssistantMessage(html) { const article = document.createElement("article"); article.className = "message assistant"; article.innerHTML = `
ИИ
${html}
`; els.messages.append(article); els.messages.scrollTop = els.messages.scrollHeight; return article; } function buildAudioUrl(blob) { state.pendingUrl = URL.createObjectURL(blob); state.objectUrls.push(state.pendingUrl); return state.pendingUrl; } function renderUserAudioEntry(entry) { const article = document.createElement("article"); article.className = "message user"; article.dataset.entryId = String(entry.id); article.innerHTML = `
Вы
Голосовое сообщение
${entry.selectionText}
`; article.addEventListener("click", (event) => { if (event.target instanceof HTMLAudioElement) { return; } if (event.target instanceof HTMLButtonElement) { return; } loadHistoryEntry(entry.id); }); els.messages.append(article); els.messages.scrollTop = els.messages.scrollHeight; return article; } function renderComparisonMessage(results, sourceLabel) { const html = `
${escapeHtml(sourceLabel)}
${results.map(renderResult).join("")}
`; appendAssistantMessage(html); } function renderResult(result) { const bars = result.probabilities .map((item) => { const percent = Math.round(item.value * 1000) / 10; return `
${escapeHtml(item.title)}
${percent.toFixed(1)}%
`; }) .join(""); return `
${escapeHtml(result.kind.toUpperCase())}

${escapeHtml(result.title)}

accuracy ${result.accuracyText}
macro F1 ${result.macroF1Text}
Визуализация эмоций
Распознано ${escapeHtml(result.emotionRu)} уверенность ${result.confidenceText}, ${result.elapsedMs} мс шаг ${Number(result.stepSec).toFixed(1)} c, кадров ${result.frameCount}
${bars}
`; } async function loadModels() { const response = await fetch("/api/models"); const data = await response.json(); state.models = data.models; state.project = data.project; const selectableModels = state.models.filter((model) => model.available); for (const select of [els.modelA, els.modelB]) { select.innerHTML = ""; selectableModels.forEach((model) => { const option = document.createElement("option"); option.value = model.key; option.textContent = modelOptionText(model); select.append(option); }); } if (selectableModels.length > 1) { els.modelA.value = selectableModels[0].key; els.modelB.value = selectableModels[1].key; } if (selectableModels.length <= 1) { els.sendBtn.disabled = true; appendAssistantMessage("Для сравнения нужно минимум две доступные модели в текущем окружении."); } if (!state.project?.hasTorch) { appendAssistantMessage("PyTorch не найден в текущем Python. ML-модели доступны, а NN-модели отображаются, но будут недоступны до установки `torch`."); } } async function startRecording() { state.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); state.audioContext = new AudioContext(); state.sampleRate = state.audioContext.sampleRate; state.source = state.audioContext.createMediaStreamSource(state.mediaStream); state.processor = state.audioContext.createScriptProcessor(4096, 1, 1); state.chunks = []; state.processor.onaudioprocess = (event) => { const input = event.inputBuffer.getChannelData(0); state.chunks.push(new Float32Array(input)); }; state.source.connect(state.processor); state.processor.connect(state.audioContext.destination); state.isRecording = true; state.pendingBlob = null; els.preview.removeAttribute("src"); els.sendBtn.disabled = true; els.recordBtn.classList.add("is-recording"); els.recordText.textContent = "Остановить"; setStatus("Запись"); } async function stopRecording() { state.isRecording = false; state.processor?.disconnect(); state.source?.disconnect(); state.mediaStream?.getTracks().forEach((track) => track.stop()); await state.audioContext?.close(); const samples = mergeChunks(state.chunks); state.pendingBlob = encodeWav(samples, state.sampleRate); const url = buildAudioUrl(state.pendingBlob); els.preview.src = url; els.sendBtn.disabled = false; els.recordBtn.classList.remove("is-recording"); els.recordText.textContent = "Записать"; setStatus("Готово"); } function mergeChunks(chunks) { const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const result = new Float32Array(length); let offset = 0; chunks.forEach((chunk) => { result.set(chunk, offset); offset += chunk.length; }); return result; } function encodeWav(samples, sampleRate) { const buffer = new ArrayBuffer(44 + samples.length * 2); const view = new DataView(buffer); writeString(view, 0, "RIFF"); view.setUint32(4, 36 + samples.length * 2, true); writeString(view, 8, "WAVE"); writeString(view, 12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); writeString(view, 36, "data"); view.setUint32(40, samples.length * 2, true); floatTo16BitPcm(view, 44, samples); return new Blob([view], { type: "audio/wav" }); } function writeString(view, offset, string) { for (let i = 0; i < string.length; i += 1) { view.setUint8(offset + i, string.charCodeAt(i)); } } function floatTo16BitPcm(view, offset, input) { for (let i = 0; i < input.length; i += 1, offset += 2) { const sample = Math.max(-1, Math.min(1, input[i])); view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true); } } function blobToDataUrl(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(blob); }); } async function collectClientMetadata(blob) { const screenInfo = window.screen || {}; const userAgentData = navigator.userAgentData || null; let highEntropy = {}; if (userAgentData?.getHighEntropyValues) { try { highEntropy = await userAgentData.getHighEntropyValues([ "architecture", "bitness", "model", "platform", "platformVersion", "uaFullVersion", "fullVersionList", ]); } catch (error) { highEntropy = { error: error.message }; } } return { capturedAt: new Date().toISOString(), url: window.location.href, referrer: document.referrer, userAgent: navigator.userAgent, platform: navigator.platform, vendor: navigator.vendor, language: navigator.language, languages: Array.from(navigator.languages || []), cookieEnabled: navigator.cookieEnabled, onLine: navigator.onLine, hardwareConcurrency: navigator.hardwareConcurrency, deviceMemory: navigator.deviceMemory, maxTouchPoints: navigator.maxTouchPoints, userAgentData: userAgentData ? { brands: Array.from(userAgentData.brands || []), mobile: userAgentData.mobile, platform: userAgentData.platform, highEntropy, } : null, device: { model: highEntropy.model || "", platform: highEntropy.platform || navigator.platform, platformVersion: highEntropy.platformVersion || "", architecture: highEntropy.architecture || "", bitness: highEntropy.bitness || "", mobile: userAgentData?.mobile ?? /android|iphone|ipad|mobile/i.test(navigator.userAgent), }, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, timezoneOffsetMin: new Date().getTimezoneOffset(), devicePixelRatio: window.devicePixelRatio, screen: { width: screenInfo.width, height: screenInfo.height, size: `${screenInfo.width || "unknown"}x${screenInfo.height || "unknown"}`, availWidth: screenInfo.availWidth, availHeight: screenInfo.availHeight, availableSize: `${screenInfo.availWidth || "unknown"}x${screenInfo.availHeight || "unknown"}`, colorDepth: screenInfo.colorDepth, pixelDepth: screenInfo.pixelDepth, orientation: screenInfo.orientation?.type, }, viewport: { width: window.innerWidth, height: window.innerHeight, size: `${window.innerWidth || "unknown"}x${window.innerHeight || "unknown"}`, visualWidth: window.visualViewport?.width, visualHeight: window.visualViewport?.height, visualSize: `${window.visualViewport?.width || "unknown"}x${window.visualViewport?.height || "unknown"}`, pageXOffset: window.scrollX, pageYOffset: window.scrollY, }, audio: { bytes: blob.size, mimeType: blob.type, sampleRate: state.sampleRate, durationMs: Math.round((state.chunks.reduce((sum, chunk) => sum + chunk.length, 0) / state.sampleRate) * 1000), chunkCount: state.chunks.length, }, }; } function validateModels() { if (els.modelA.value === els.modelB.value) { throw new Error("Выберите две разные модели."); } } async function analyzeBlob(blob, sourceLabel) { validateModels(); const audioBase64 = await blobToDataUrl(blob); setStatus("Анализ"); els.sendBtn.disabled = true; const response = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ audioBase64, modelA: els.modelA.value, modelB: els.modelB.value, client: await collectClientMetadata(blob), }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || "Ошибка анализа."); } renderComparisonMessage(data.results, sourceLabel); setStatus("Готово"); els.sendBtn.disabled = !state.pendingBlob; } async function sendCurrentAudio() { if (!state.pendingBlob) { throw new Error("Сначала запишите голосовое сообщение."); } const sourceLabel = `Текущее сообщение • ${new Date().toLocaleTimeString("ru-RU")}`; const entry = { id: state.nextId++, blob: state.pendingBlob, audioUrl: state.pendingUrl, meta: sourceLabel, selectionText: currentSelectionText(), }; state.history.push(entry); renderUserAudioEntry(entry); await analyzeBlob(state.pendingBlob, sourceLabel); } function loadHistoryEntry(entryId) { const entry = state.history.find((item) => item.id === entryId); if (!entry) { return; } state.pendingBlob = entry.blob; state.pendingUrl = entry.audioUrl; els.preview.src = entry.audioUrl; els.sendBtn.disabled = false; setStatus("Готово"); } els.recordBtn.addEventListener("click", async () => { try { if (state.isRecording) { await stopRecording(); } else { await startRecording(); } } catch (error) { setStatus("Ошибка"); appendAssistantMessage(escapeHtml(error.message)); } }); els.sendBtn.addEventListener("click", async () => { try { await sendCurrentAudio(); } catch (error) { setStatus("Ошибка"); els.sendBtn.disabled = !state.pendingBlob; appendAssistantMessage(escapeHtml(error.message)); } }); loadModels().catch((error) => { setStatus("Ошибка"); appendAssistantMessage(escapeHtml(error.message)); }); window.addEventListener("beforeunload", () => { state.objectUrls.forEach((url) => URL.revokeObjectURL(url)); });