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.meta}
${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));
});