Initial voice call center app
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Simple call-center voice app."""
|
||||
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
TOKEN_RE = re.compile(r"[0-9A-Za-zА-Яа-яЁёӘәҒғҚқҢңӨөҰұҮүҺһІі]+", re.UNICODE)
|
||||
WHITESPACE_RE = re.compile(r"\s+")
|
||||
STOPWORDS = {
|
||||
"а",
|
||||
"без",
|
||||
"бы",
|
||||
"в",
|
||||
"во",
|
||||
"где",
|
||||
"для",
|
||||
"до",
|
||||
"его",
|
||||
"ее",
|
||||
"если",
|
||||
"за",
|
||||
"и",
|
||||
"или",
|
||||
"как",
|
||||
"какая",
|
||||
"какие",
|
||||
"какой",
|
||||
"когда",
|
||||
"кто",
|
||||
"ли",
|
||||
"мне",
|
||||
"можно",
|
||||
"мы",
|
||||
"на",
|
||||
"надо",
|
||||
"не",
|
||||
"нет",
|
||||
"но",
|
||||
"о",
|
||||
"об",
|
||||
"по",
|
||||
"подскажите",
|
||||
"пожалуйста",
|
||||
"при",
|
||||
"с",
|
||||
"со",
|
||||
"то",
|
||||
"у",
|
||||
"что",
|
||||
"чтобы",
|
||||
"это",
|
||||
"я",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnowledgeHit:
|
||||
score: float
|
||||
record: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnowledgeDocument:
|
||||
record: dict[str, Any]
|
||||
title_tokens: set[str]
|
||||
keyword_tokens: set[str]
|
||||
content_tokens: Counter[str]
|
||||
normalized_text: str
|
||||
|
||||
|
||||
class KnowledgeBase:
|
||||
def __init__(self, documents: list[KnowledgeDocument], source_files: list[str]):
|
||||
self.documents = documents
|
||||
self.source_files = source_files
|
||||
self._idf = self._build_idf(documents)
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> "KnowledgeBase":
|
||||
return cls([], [])
|
||||
|
||||
@classmethod
|
||||
def from_directory(cls, directory: str) -> "KnowledgeBase":
|
||||
root = Path(directory)
|
||||
if not root.exists():
|
||||
return cls.empty()
|
||||
|
||||
records: list[dict[str, Any]] = []
|
||||
source_files: list[str] = []
|
||||
for path in sorted(root.glob("*.jsonl")):
|
||||
source_files.append(str(path))
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
for line in file:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
records.append(json.loads(line))
|
||||
|
||||
documents = [build_document(record) for record in records]
|
||||
return cls(documents, source_files)
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self.documents)
|
||||
|
||||
def search(self, query: str, limit: int = 4, min_score: float = 2.0) -> list[KnowledgeHit]:
|
||||
query_tokens = tokenize(query)
|
||||
if not query_tokens:
|
||||
return []
|
||||
|
||||
query_text = normalize_text(query)
|
||||
hits: list[KnowledgeHit] = []
|
||||
for document in self.documents:
|
||||
score = self._score_document(document, query_tokens, query_text)
|
||||
if score >= min_score:
|
||||
hits.append(KnowledgeHit(score=score, record=document.record))
|
||||
|
||||
hits.sort(key=lambda hit: hit.score, reverse=True)
|
||||
return hits[:limit]
|
||||
|
||||
def _score_document(
|
||||
self,
|
||||
document: KnowledgeDocument,
|
||||
query_tokens: list[str],
|
||||
query_text: str,
|
||||
) -> float:
|
||||
unique_query_tokens = set(query_tokens)
|
||||
score = 0.0
|
||||
for token in unique_query_tokens:
|
||||
idf = self._idf.get(token, 1.0)
|
||||
if token in document.title_tokens:
|
||||
score += 3.4 * idf
|
||||
if token in document.keyword_tokens:
|
||||
score += 2.6 * idf
|
||||
count = document.content_tokens.get(token, 0)
|
||||
if count:
|
||||
score += min(count, 3) * idf
|
||||
|
||||
if query_text and query_text in document.normalized_text:
|
||||
score += 8.0
|
||||
elif len(unique_query_tokens) >= 3:
|
||||
overlap = len(unique_query_tokens & set(document.content_tokens))
|
||||
score += overlap / len(unique_query_tokens)
|
||||
|
||||
return score
|
||||
|
||||
@staticmethod
|
||||
def _build_idf(documents: list[KnowledgeDocument]) -> dict[str, float]:
|
||||
document_count = max(len(documents), 1)
|
||||
document_frequency: Counter[str] = Counter()
|
||||
for document in documents:
|
||||
document_frequency.update(set(document.content_tokens))
|
||||
|
||||
return {
|
||||
token: math.log((document_count + 1) / (frequency + 1)) + 1
|
||||
for token, frequency in document_frequency.items()
|
||||
}
|
||||
|
||||
|
||||
def build_document(record: dict[str, Any]) -> KnowledgeDocument:
|
||||
title_text = " ".join(
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
record.get("title", ""),
|
||||
record.get("question", ""),
|
||||
record.get("category", ""),
|
||||
record.get("region_code", ""),
|
||||
],
|
||||
)
|
||||
)
|
||||
keywords = record.get("keywords") or []
|
||||
keyword_text = " ".join(str(keyword) for keyword in keywords)
|
||||
content_text = " ".join(
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
record.get("content", ""),
|
||||
record.get("short_answer", ""),
|
||||
record.get("full_answer", ""),
|
||||
keyword_text,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
return KnowledgeDocument(
|
||||
record=record,
|
||||
title_tokens=set(tokenize(title_text)),
|
||||
keyword_tokens=set(tokenize(keyword_text)),
|
||||
content_tokens=Counter(tokenize(content_text)),
|
||||
normalized_text=normalize_text(f"{title_text} {content_text}"),
|
||||
)
|
||||
|
||||
|
||||
def format_knowledge_context(hits: list[KnowledgeHit], max_chars: int = 4200) -> str:
|
||||
if not hits:
|
||||
return ""
|
||||
|
||||
parts = [
|
||||
"Служебный контекст базы знаний Aimaq. Используй только релевантные факты из этого блока. "
|
||||
"Не называй клиенту ID записей и внутренние источники."
|
||||
]
|
||||
current_len = len(parts[0])
|
||||
for index, hit in enumerate(hits, start=1):
|
||||
record = hit.record
|
||||
entry = format_record(index, hit.score, record)
|
||||
if current_len + len(entry) > max_chars:
|
||||
break
|
||||
parts.append(entry)
|
||||
current_len += len(entry)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def format_record(index: int, score: float, record: dict[str, Any]) -> str:
|
||||
title = record.get("title") or record.get("question") or "Без названия"
|
||||
category = record.get("category") or "general"
|
||||
region = record.get("region_code") or "global"
|
||||
short_answer = record.get("short_answer") or ""
|
||||
full_answer = record.get("full_answer") or ""
|
||||
content = record.get("content") or ""
|
||||
|
||||
body = full_answer or short_answer or content
|
||||
if short_answer and full_answer and short_answer not in full_answer:
|
||||
body = f"{short_answer}\n{full_answer}"
|
||||
body = trim_text(body, 1100)
|
||||
|
||||
return (
|
||||
f"[KB {index}] score={score:.2f}\n"
|
||||
f"ID: {record.get('external_id', '')}\n"
|
||||
f"Категория: {category}\n"
|
||||
f"Регион: {region}\n"
|
||||
f"Заголовок: {title}\n"
|
||||
f"Ответ/факты: {body}"
|
||||
)
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
tokens = []
|
||||
for token in TOKEN_RE.findall(text.casefold().replace("ё", "е")):
|
||||
if len(token) < 2 or token in STOPWORDS:
|
||||
continue
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
text = text.casefold().replace("ё", "е")
|
||||
text = " ".join(TOKEN_RE.findall(text))
|
||||
return WHITESPACE_RE.sub(" ", text).strip()
|
||||
|
||||
|
||||
def trim_text(text: str, max_chars: int) -> str:
|
||||
text = WHITESPACE_RE.sub(" ", text).strip()
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
return text[: max_chars - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def resolve_knowledge_dir(project_dir: str, configured_dir: str) -> str:
|
||||
if os.path.isabs(configured_dir):
|
||||
return configured_dir
|
||||
return os.path.join(project_dir, configured_dir)
|
||||
+1366
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
||||
const TARGET_SAMPLE_RATE = 16000;
|
||||
|
||||
const startBtn = document.getElementById("startBtn");
|
||||
const stopBtn = document.getElementById("stopBtn");
|
||||
const resetBtn = document.getElementById("resetBtn");
|
||||
const statusEl = document.getElementById("status");
|
||||
const partialLine = document.getElementById("partialLine");
|
||||
const messagesEl = document.getElementById("messages");
|
||||
const canvas = document.getElementById("waveform");
|
||||
const canvasCtx = canvas.getContext("2d");
|
||||
const levelEl = document.getElementById("level");
|
||||
const muteLabel = document.getElementById("muteLabel");
|
||||
|
||||
let socket;
|
||||
let mediaStream;
|
||||
let audioContext;
|
||||
let sourceNode;
|
||||
let processorNode;
|
||||
let isRunning = false;
|
||||
let isStopping = false;
|
||||
let muteMic = false;
|
||||
let currentAssistantLine = null;
|
||||
let currentAssistantText = "";
|
||||
let currentAssistantAudioChunks = 0;
|
||||
let currentAssistantTtsFailed = false;
|
||||
let currentAssistantTtsMessage = "";
|
||||
let lastErrorMessage = "";
|
||||
let playbackCursor = 0;
|
||||
let pendingUnmuteTimer = null;
|
||||
let encodedPlaybackChain = Promise.resolve();
|
||||
let pendingEncodedAudioCount = 0;
|
||||
const activeEncodedAudios = new Set();
|
||||
|
||||
drawIdleWave();
|
||||
|
||||
startBtn.addEventListener("click", startCall);
|
||||
stopBtn.addEventListener("click", stopCall);
|
||||
resetBtn.addEventListener("click", resetSession);
|
||||
|
||||
async function startCall() {
|
||||
lastErrorMessage = "";
|
||||
isStopping = false;
|
||||
startBtn.disabled = true;
|
||||
setStatus("connecting", "busy");
|
||||
|
||||
try {
|
||||
socket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.addEventListener("message", handleServerMessage);
|
||||
socket.addEventListener("close", () => {
|
||||
if (isRunning) stopCall(lastErrorMessage || "Звонок остановлен.");
|
||||
if (!statusEl.classList.contains("error")) setStatus("offline", "");
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
lastErrorMessage = "Ошибка WebSocket.";
|
||||
setStatus("socket error", "error");
|
||||
partialLine.textContent = lastErrorMessage;
|
||||
addMessage("system", lastErrorMessage);
|
||||
});
|
||||
|
||||
await waitForSocketOpen(socket);
|
||||
await waitForServerReady(socket);
|
||||
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
channelCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
audioContext = new AudioContext();
|
||||
await audioContext.resume();
|
||||
|
||||
sourceNode = audioContext.createMediaStreamSource(mediaStream);
|
||||
processorNode = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
processorNode.onaudioprocess = handleAudioProcess;
|
||||
sourceNode.connect(processorNode);
|
||||
processorNode.connect(audioContext.destination);
|
||||
|
||||
isRunning = true;
|
||||
stopBtn.disabled = false;
|
||||
resetBtn.disabled = false;
|
||||
setStatus("live", "live");
|
||||
partialLine.textContent = "Слушаю...";
|
||||
socket.send(JSON.stringify({ type: "start_greeting" }));
|
||||
} catch (error) {
|
||||
lastErrorMessage = error.message || "Не удалось начать звонок.";
|
||||
await cleanupAfterFailedStart();
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
resetBtn.disabled = true;
|
||||
setStatus("error", "error");
|
||||
partialLine.textContent = lastErrorMessage;
|
||||
addMessage("system", lastErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCall(message = "Звонок остановлен.") {
|
||||
if (isStopping) return;
|
||||
isStopping = true;
|
||||
isRunning = false;
|
||||
muteMic = false;
|
||||
updateMuteLabel();
|
||||
|
||||
if (processorNode) {
|
||||
processorNode.disconnect();
|
||||
processorNode.onaudioprocess = null;
|
||||
}
|
||||
if (sourceNode) sourceNode.disconnect();
|
||||
if (mediaStream) mediaStream.getTracks().forEach((track) => track.stop());
|
||||
if (socket && socket.readyState === WebSocket.OPEN) socket.close();
|
||||
if (audioContext && audioContext.state !== "closed") await audioContext.close();
|
||||
|
||||
processorNode = null;
|
||||
sourceNode = null;
|
||||
mediaStream = null;
|
||||
audioContext = null;
|
||||
socket = null;
|
||||
currentAssistantLine = null;
|
||||
currentAssistantText = "";
|
||||
currentAssistantAudioChunks = 0;
|
||||
currentAssistantTtsFailed = false;
|
||||
currentAssistantTtsMessage = "";
|
||||
playbackCursor = 0;
|
||||
pendingEncodedAudioCount = 0;
|
||||
activeEncodedAudios.forEach((audio) => {
|
||||
audio.pause();
|
||||
audio.src = "";
|
||||
});
|
||||
activeEncodedAudios.clear();
|
||||
encodedPlaybackChain = Promise.resolve();
|
||||
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
resetBtn.disabled = true;
|
||||
partialLine.textContent = message;
|
||||
levelEl.style.width = "0%";
|
||||
drawIdleWave();
|
||||
isStopping = false;
|
||||
}
|
||||
|
||||
function resetSession() {
|
||||
messagesEl.innerHTML = "";
|
||||
currentAssistantLine = null;
|
||||
currentAssistantText = "";
|
||||
currentAssistantAudioChunks = 0;
|
||||
currentAssistantTtsFailed = false;
|
||||
currentAssistantTtsMessage = "";
|
||||
partialLine.textContent = "Контекст очищен.";
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "reset" }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioProcess(event) {
|
||||
const input = event.inputBuffer.getChannelData(0);
|
||||
drawWave(input);
|
||||
updateLevel(input);
|
||||
|
||||
if (!isRunning || muteMic || !socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const downsampled = downsample(input, audioContext.sampleRate, TARGET_SAMPLE_RATE);
|
||||
const pcm = floatTo16BitPcm(downsampled);
|
||||
socket.send(pcm);
|
||||
}
|
||||
|
||||
function handleServerMessage(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === "ready") {
|
||||
setStatus(data.model || "ready", "live");
|
||||
} else if (data.type === "stt_ready") {
|
||||
partialLine.textContent = "Слушаю...";
|
||||
} else if (data.type === "stt_partial") {
|
||||
partialLine.textContent = data.text || "Слушаю...";
|
||||
} else if (data.type === "user_final") {
|
||||
currentAssistantLine = null;
|
||||
addMessage("user", data.text);
|
||||
partialLine.textContent = "Думаю...";
|
||||
} else if (data.type === "assistant_started") {
|
||||
muteMic = true;
|
||||
updateMuteLabel();
|
||||
currentAssistantLine = addMessage("assistant", "");
|
||||
currentAssistantText = "";
|
||||
currentAssistantAudioChunks = 0;
|
||||
currentAssistantTtsFailed = false;
|
||||
currentAssistantTtsMessage = "";
|
||||
setStatus("answering", "busy");
|
||||
} else if (data.type === "assistant_delta") {
|
||||
if (!currentAssistantLine) currentAssistantLine = addMessage("assistant", "");
|
||||
currentAssistantLine.textContent += data.text;
|
||||
currentAssistantText += data.text || "";
|
||||
scrollTranscript();
|
||||
} else if (data.type === "assistant_text_done") {
|
||||
currentAssistantText = data.text || currentAssistantText;
|
||||
partialLine.textContent = "Озвучиваю...";
|
||||
} else if (data.type === "tts_audio") {
|
||||
currentAssistantAudioChunks += 1;
|
||||
if ((data.format || "").startsWith("mp3") || (data.mime_type || "").includes("mpeg")) {
|
||||
playEncodedAudio(data.audio, data.mime_type || "audio/mpeg");
|
||||
} else {
|
||||
playPcmAudio(data.audio, data.sample_rate || TARGET_SAMPLE_RATE);
|
||||
}
|
||||
} else if (data.type === "tts_failed") {
|
||||
currentAssistantTtsFailed = true;
|
||||
currentAssistantTtsMessage = data.message || "ElevenLabs TTS не прислал аудио.";
|
||||
partialLine.textContent = "ElevenLabs TTS недоступен, включаю резервную озвучку.";
|
||||
addMessage("system", "ElevenLabs TTS недоступен, включаю резервную озвучку.");
|
||||
} else if (data.type === "assistant_done") {
|
||||
const fallbackStarted = maybeSpeakWithBrowserFallback();
|
||||
if (!fallbackStarted) {
|
||||
scheduleUnmuteAfterPlayback();
|
||||
setStatus(currentAssistantTtsFailed ? "tts fallback" : "live", currentAssistantTtsFailed ? "busy" : "live");
|
||||
partialLine.textContent = currentAssistantTtsFailed ? currentAssistantTtsMessage : "Слушаю...";
|
||||
}
|
||||
} else if (data.type === "reset_done") {
|
||||
partialLine.textContent = "Контекст очищен.";
|
||||
if (isRunning && socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "start_greeting" }));
|
||||
}
|
||||
} else if (data.type === "error") {
|
||||
lastErrorMessage = data.message || "Ошибка.";
|
||||
partialLine.textContent = lastErrorMessage;
|
||||
addMessage("system", lastErrorMessage);
|
||||
setStatus("error", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(role, text) {
|
||||
const line = document.createElement("div");
|
||||
line.className = `line ${role}`;
|
||||
line.textContent = text;
|
||||
messagesEl.appendChild(line);
|
||||
scrollTranscript();
|
||||
return line;
|
||||
}
|
||||
|
||||
function scrollTranscript() {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
transcript.scrollTop = transcript.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text, className) {
|
||||
statusEl.textContent = text;
|
||||
statusEl.className = `status-pill ${className || ""}`.trim();
|
||||
}
|
||||
|
||||
function maybeSpeakWithBrowserFallback() {
|
||||
if (currentAssistantAudioChunks > 0) return false;
|
||||
if (!currentAssistantText.trim()) return false;
|
||||
if (!("speechSynthesis" in window) || typeof SpeechSynthesisUtterance === "undefined") {
|
||||
partialLine.textContent = currentAssistantTtsFailed
|
||||
? currentAssistantTtsMessage
|
||||
: "TTS не прислал аудио.";
|
||||
return false;
|
||||
}
|
||||
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(currentAssistantText);
|
||||
utterance.lang = "ru-RU";
|
||||
utterance.rate = 1.04;
|
||||
utterance.pitch = 1;
|
||||
utterance.onend = finishBrowserFallbackSpeech;
|
||||
utterance.onerror = finishBrowserFallbackSpeech;
|
||||
|
||||
muteMic = true;
|
||||
updateMuteLabel();
|
||||
setStatus("browser voice", "busy");
|
||||
partialLine.textContent = currentAssistantTtsFailed
|
||||
? "Резервная озвучка включена: ElevenLabs TTS не дал аудио."
|
||||
: "Резервная озвучка включена.";
|
||||
window.speechSynthesis.speak(utterance);
|
||||
return true;
|
||||
}
|
||||
|
||||
function finishBrowserFallbackSpeech() {
|
||||
muteMic = false;
|
||||
updateMuteLabel();
|
||||
setStatus("live", "live");
|
||||
partialLine.textContent = "Слушаю...";
|
||||
}
|
||||
|
||||
function updateMuteLabel() {
|
||||
muteLabel.textContent = muteMic ? "muted" : "open";
|
||||
}
|
||||
|
||||
function waitForSocketOpen(ws) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ws.addEventListener("open", resolve, { once: true });
|
||||
ws.addEventListener("error", reject, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function waitForServerReady(ws) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Сервер не ответил."));
|
||||
}, 6000);
|
||||
|
||||
function cleanup() {
|
||||
window.clearTimeout(timeout);
|
||||
ws.removeEventListener("message", onMessage);
|
||||
ws.removeEventListener("close", onClose);
|
||||
ws.removeEventListener("error", onError);
|
||||
}
|
||||
|
||||
function onMessage(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "ready") {
|
||||
cleanup();
|
||||
resolve(data);
|
||||
} else if (data.type === "error") {
|
||||
cleanup();
|
||||
reject(new Error(data.message || "Сервер вернул ошибку."));
|
||||
}
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
cleanup();
|
||||
reject(new Error("WebSocket закрыт."));
|
||||
}
|
||||
|
||||
function onError() {
|
||||
cleanup();
|
||||
reject(new Error("Ошибка WebSocket."));
|
||||
}
|
||||
|
||||
ws.addEventListener("message", onMessage);
|
||||
ws.addEventListener("close", onClose);
|
||||
ws.addEventListener("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupAfterFailedStart() {
|
||||
if (processorNode) {
|
||||
processorNode.disconnect();
|
||||
processorNode.onaudioprocess = null;
|
||||
}
|
||||
if (sourceNode) sourceNode.disconnect();
|
||||
if (mediaStream) mediaStream.getTracks().forEach((track) => track.stop());
|
||||
if (socket && socket.readyState === WebSocket.OPEN) socket.close();
|
||||
if (audioContext) await audioContext.close();
|
||||
|
||||
processorNode = null;
|
||||
sourceNode = null;
|
||||
mediaStream = null;
|
||||
audioContext = null;
|
||||
socket = null;
|
||||
}
|
||||
|
||||
function downsample(buffer, inputRate, outputRate) {
|
||||
if (outputRate === inputRate) return buffer;
|
||||
const ratio = inputRate / outputRate;
|
||||
const newLength = Math.round(buffer.length / ratio);
|
||||
const result = new Float32Array(newLength);
|
||||
let offsetResult = 0;
|
||||
let offsetBuffer = 0;
|
||||
|
||||
while (offsetResult < result.length) {
|
||||
const nextOffsetBuffer = Math.round((offsetResult + 1) * ratio);
|
||||
let accumulator = 0;
|
||||
let count = 0;
|
||||
for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i += 1) {
|
||||
accumulator += buffer[i];
|
||||
count += 1;
|
||||
}
|
||||
result[offsetResult] = accumulator / Math.max(count, 1);
|
||||
offsetResult += 1;
|
||||
offsetBuffer = nextOffsetBuffer;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function floatTo16BitPcm(floatBuffer) {
|
||||
const output = new Int16Array(floatBuffer.length);
|
||||
for (let i = 0; i < floatBuffer.length; i += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, floatBuffer[i]));
|
||||
output[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||
}
|
||||
return output.buffer;
|
||||
}
|
||||
|
||||
function playPcmAudio(base64Audio, sampleRate) {
|
||||
if (!audioContext) return;
|
||||
if (audioContext.state === "suspended") audioContext.resume();
|
||||
const bytes = Uint8Array.from(atob(base64Audio), (char) => char.charCodeAt(0));
|
||||
const samples = new Int16Array(bytes.buffer);
|
||||
const audioBuffer = audioContext.createBuffer(1, samples.length, sampleRate);
|
||||
const channel = audioBuffer.getChannelData(0);
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
channel[i] = samples[i] / 32768;
|
||||
}
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(audioContext.destination);
|
||||
const startAt = Math.max(audioContext.currentTime + 0.03, playbackCursor);
|
||||
source.start(startAt);
|
||||
playbackCursor = startAt + audioBuffer.duration;
|
||||
}
|
||||
|
||||
function playEncodedAudio(base64Audio, mimeType) {
|
||||
pendingEncodedAudioCount += 1;
|
||||
encodedPlaybackChain = encodedPlaybackChain.then(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const bytes = Uint8Array.from(atob(base64Audio), (char) => char.charCodeAt(0));
|
||||
const blob = new Blob([bytes], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
activeEncodedAudios.add(audio);
|
||||
|
||||
function cleanup() {
|
||||
URL.revokeObjectURL(url);
|
||||
activeEncodedAudios.delete(audio);
|
||||
pendingEncodedAudioCount = Math.max(0, pendingEncodedAudioCount - 1);
|
||||
if (pendingEncodedAudioCount === 0 && muteMic) {
|
||||
scheduleUnmuteAfterPlayback();
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
|
||||
audio.onended = cleanup;
|
||||
audio.onerror = cleanup;
|
||||
audio.play().catch(cleanup);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleUnmuteAfterPlayback() {
|
||||
if (pendingEncodedAudioCount > 0) return;
|
||||
if (pendingUnmuteTimer) window.clearTimeout(pendingUnmuteTimer);
|
||||
const remainingMs = audioContext ? Math.max(0, (playbackCursor - audioContext.currentTime) * 1000) : 0;
|
||||
pendingUnmuteTimer = window.setTimeout(() => {
|
||||
muteMic = false;
|
||||
updateMuteLabel();
|
||||
}, remainingMs + 120);
|
||||
}
|
||||
|
||||
function updateLevel(buffer) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buffer.length; i += 1) sum += buffer[i] * buffer[i];
|
||||
const rms = Math.sqrt(sum / buffer.length);
|
||||
const percent = Math.min(100, Math.round(rms * 260));
|
||||
levelEl.style.width = `${percent}%`;
|
||||
}
|
||||
|
||||
function drawIdleWave() {
|
||||
canvasCtx.fillStyle = "#101820";
|
||||
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
canvasCtx.strokeStyle = "#2f9e80";
|
||||
canvasCtx.lineWidth = 3;
|
||||
canvasCtx.beginPath();
|
||||
const mid = canvas.height / 2;
|
||||
for (let x = 0; x < canvas.width; x += 1) {
|
||||
const y = mid + Math.sin(x / 28) * 10 + Math.sin(x / 83) * 18;
|
||||
if (x === 0) canvasCtx.moveTo(x, y);
|
||||
else canvasCtx.lineTo(x, y);
|
||||
}
|
||||
canvasCtx.stroke();
|
||||
}
|
||||
|
||||
function drawWave(buffer) {
|
||||
canvasCtx.fillStyle = "#101820";
|
||||
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
canvasCtx.strokeStyle = muteMic ? "#d9563f" : "#36c28f";
|
||||
canvasCtx.lineWidth = 3;
|
||||
canvasCtx.beginPath();
|
||||
const slice = Math.max(1, Math.floor(buffer.length / canvas.width));
|
||||
const mid = canvas.height / 2;
|
||||
|
||||
for (let x = 0; x < canvas.width; x += 1) {
|
||||
const sample = buffer[x * slice] || 0;
|
||||
const y = mid + sample * mid * 0.84;
|
||||
if (x === 0) canvasCtx.moveTo(x, y);
|
||||
else canvasCtx.lineTo(x, y);
|
||||
}
|
||||
canvasCtx.stroke();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Simple Call Center</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="workspace" aria-label="Voice session">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Live voice session</p>
|
||||
<h1>Simple Call Center</h1>
|
||||
</div>
|
||||
<div class="status-pill" id="status">offline</div>
|
||||
</header>
|
||||
|
||||
<section class="stage" aria-label="Audio monitor">
|
||||
<canvas id="waveform" width="1200" height="240"></canvas>
|
||||
<div class="meter-row">
|
||||
<div class="meter-label">Mic</div>
|
||||
<div class="level-track"><div id="level" class="level-fill"></div></div>
|
||||
<div class="meter-label" id="muteLabel">open</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="controls" aria-label="Call controls">
|
||||
<button id="startBtn" class="primary" type="button">Start</button>
|
||||
<button id="stopBtn" type="button" disabled>Stop</button>
|
||||
<button id="resetBtn" type="button" disabled>Reset</button>
|
||||
</section>
|
||||
|
||||
<section class="transcript" aria-live="polite" aria-label="Transcript">
|
||||
<div class="line system" id="partialLine">Готов к звонку.</div>
|
||||
<div id="messages" class="messages"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.js?v=mp3-192-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,259 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #eef2f6;
|
||||
--ink: #101820;
|
||||
--muted: #5f6f7a;
|
||||
--line: #cfdae5;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f6f8fb;
|
||||
--green: #0f8f61;
|
||||
--green-dark: #08734d;
|
||||
--blue: #1f6feb;
|
||||
--coral: #d9563f;
|
||||
--amber: #b7791f;
|
||||
--shadow: 0 18px 48px rgba(16, 24, 32, 0.12);
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(238, 242, 246, 0.94), rgba(226, 234, 241, 0.98)),
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cg fill='none' stroke='%23c4d1dc' stroke-opacity='.45'%3E%3Cpath d='M0 40h160M0 80h160M0 120h160M40 0v160M80 0v160M120 0v160'/%3E%3C/g%3E%3C/svg%3E");
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
button {
|
||||
min-width: 112px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 140ms ease,
|
||||
border-color 140ms ease,
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
border-color: #9fb1c0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
border-color: var(--green);
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1120px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(26px, 3vw, 40px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
min-width: 116px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-pill.live {
|
||||
border-color: rgba(15, 143, 97, 0.32);
|
||||
background: rgba(15, 143, 97, 0.11);
|
||||
color: var(--green-dark);
|
||||
}
|
||||
|
||||
.status-pill.busy {
|
||||
border-color: rgba(31, 111, 235, 0.3);
|
||||
background: rgba(31, 111, 235, 0.1);
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.status-pill.error {
|
||||
border-color: rgba(217, 86, 63, 0.35);
|
||||
background: rgba(217, 86, 63, 0.12);
|
||||
color: var(--coral);
|
||||
}
|
||||
|
||||
.stage {
|
||||
padding: 22px 24px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
#waveform {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: clamp(160px, 25vw, 240px);
|
||||
border: 1px solid #c8d6df;
|
||||
border-radius: 8px;
|
||||
background: #101820;
|
||||
}
|
||||
|
||||
.meter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(120px, 1fr) 72px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.meter-label {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.level-track {
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #dbe5ed;
|
||||
}
|
||||
|
||||
.level-fill {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--green), var(--amber), var(--coral));
|
||||
transition: width 80ms linear;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.transcript {
|
||||
min-height: 260px;
|
||||
max-height: 44vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 24px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.line {
|
||||
max-width: 82%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.line.system {
|
||||
max-width: none;
|
||||
margin-bottom: 14px;
|
||||
border-color: #d8dde3;
|
||||
background: #eef4f8;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.line.user {
|
||||
justify-self: start;
|
||||
border-color: rgba(15, 143, 97, 0.26);
|
||||
background: rgba(15, 143, 97, 0.08);
|
||||
}
|
||||
|
||||
.line.assistant {
|
||||
justify-self: end;
|
||||
border-color: rgba(31, 111, 235, 0.24);
|
||||
background: rgba(31, 111, 235, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.shell {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-height: 100vh;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.line {
|
||||
max-width: 94%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user