<!DOCTYPE html>
<html lang="bg">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#080808">
<title>THE BIGGEST DAN</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(ellipse at center, #211b0e 0%, #10100e 35%, #080808 75%);
color: #f5f0e5;
font-family: Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.app {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 45px 20px 28px;
}
.brand { text-align: center; }
.brand h1 {
color: #d6b35c;
font-size: clamp(23px, 5vw, 34px);
letter-spacing: 5px;
margin: 0;
font-weight: 800;
}
.brand p {
color: #888;
font-size: 10px;
letter-spacing: 5px;
margin-top: 12px;
}
.center {
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
}
.orb-wrap {
width: 230px;
height: 230px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 1px solid rgba(214,179,92,.25);
border-radius: 50%;
transition: .3s;
pointer-events: none;
}
.orb {
position: relative;
z-index: 1;
width: 160px;
height: 160px;
border-radius: 50%;
border: 2px solid #d6b35c;
background: radial-gradient(circle at 35% 30%, #51401d, #1c180e 65%, #0b0b0b);
box-shadow: 0 0 30px rgba(214,179,92,.15), inset 0 0 30px rgba(214,179,92,.15);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: .3s;
color: #e1c477;
}
.orb:hover {
transform: scale(1.04);
box-shadow: 0 0 55px rgba(214,179,92,.35);
}
.orb:disabled {
opacity: .5;
cursor: wait;
}
.orb svg {
pointer-events: none;
width: 48px;
height: 48px;
}
body.listening .ring {
animation: pulse 1.5s infinite;
border-color: #d6b35c;
}
body.listening .orb {
box-shadow: 0 0 65px rgba(214,179,92,.5);
}
body.speaking .ring {
animation: pulse .8s infinite;
border-color: #f1d78f;
}
@keyframes pulse {
0% { transform: scale(1); opacity: .8; }
100% { transform: scale(1.35); opacity: 0; }
}
.status { text-align: center; }
#state {
color: #d6b35c;
font-size: 15px;
letter-spacing: 2px;
font-weight: bold;
}
#hint {
color: #888;
font-size: 12px;
margin-top: 10px;
min-height: 18px;
text-align: center;
max-width: 300px;
}
#stop {
background: transparent;
border: 1px solid #514326;
color: #c5a85d;
padding: 12px 24px;
border-radius: 30px;
cursor: pointer;
letter-spacing: 1px;
font-size: 11px;
}
#stop:hover { background: #211b0e; }
.bottom {
color: #555;
font-size: 10px;
letter-spacing: 2px;
text-align: center;
}
.permission-screen {
position: fixed;
inset: 105px 12px auto 12px;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
pointer-events: none;
}
.permission-screen[hidden] {
display: none;
}
.permission-card {
pointer-events: auto;
width: min(100%, 420px);
padding: 32px 24px;
border: 1px solid rgba(214,179,92,.45);
border-radius: 22px;
background: radial-gradient(ellipse at top, #211b0e, #10100e 68%);
text-align: center;
box-shadow: 0 0 50px rgba(214,179,92,.12);
}
.permission-card h2 {
margin: 0 0 14px;
color: #d6b35c;
font-size: 20px;
letter-spacing: 2px;
}
.permission-card p {
margin: 0 0 22px;
color: #c7c0b1;
font-size: 14px;
line-height: 1.6;
}
#permissionButton {
width: 100%;
min-height: 52px;
padding: 14px 18px;
border: 1px solid #d6b35c;
border-radius: 30px;
background: #d6b35c;
color: #111;
font-size: 12px;
font-weight: 800;
letter-spacing: 1.5px;
cursor: pointer;
}
#permissionButton:disabled {
opacity: .65;
cursor: wait;
}
#permissionMessage {
min-height: 20px;
margin-top: 16px;
color: #c5a85d;
font-size: 12px;
line-height: 1.5;
}
.progress-track {
width: 100%;
height: 8px;
margin-top: 20px;
overflow: hidden;
border-radius: 20px;
background: #302819;
}
#downloadBar {
width: 0%;
height: 100%;
border-radius: 20px;
background: linear-gradient(90deg, #9a7835, #f1d78f);
transition: width .2s ease;
}
@media (max-height: 650px) {
.app { padding: 25px 15px; }
.orb-wrap { width: 190px; height: 190px; }
.orb { width: 135px; height: 135px; }
}
</style>
</head>
<body>
<div class="permission-screen" id="permissionScreen" role="dialog" aria-modal="true">
<div class="permission-card">
<h2 id="permissionTitle">THE BIGGEST DAN</h2>
<p id="permissionDescription">
ÐодгоÑвÑм бÑлгаÑÑÐºÐ¸Ñ Ð³Ð»Ð°Ñ ÐимиÑÑÑ.
</p>
<div class="progress-track">
<div id="downloadBar"></div>
</div>
<button id="permissionButton" type="button" hidden>
ÐÐÐТÐÐ ÐТÐÐÐÐ
</button>
<div id="permissionMessage" role="status" aria-live="polite">
ÐодгоÑвÑм глаÑа...
</div>
</div>
</div>
<div class="app">
<header class="brand">
<h1>THE BIGGEST DAN</h1>
<p>VOICE INTELLIGENCE</p>
</header>
<section class="center">
<div class="orb-wrap">
<div class="ring" aria-hidden="true"></div>
<button class="orb" id="mic" aria-label="ÐапоÑни ÑазговоÑ" type="button" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="13" rx="3"/>
<path d="M5 10v2a7 7 0 0 0 14 0v-2"/>
<path d="M12 19v3m-4 0h8"/>
</svg>
</button>
</div>
<div class="status">
<div id="state">ÐÐÐ ÐÐÐÐÐ ÐÐÐСÐ</div>
<div id="hint">ÐодгоÑвÑм ÐимиÑÑÑ...</div>
</div>
<button id="stop" type="button">ÐÐ ÐÐÐЮЧРРÐÐÐÐÐÐÐ Ð</button>
</section>
<footer class="bottom">PRIVATE VOICE EXPERIENCE</footer>
</div>
<script type="module">
"use strict";
// ========================================
// ÐÐСТРÐÐÐÐ
// ========================================
const API =
"https://growshop30.albaniangoats.workers.dev/chat";
const VOICE_ID = "bg_BG-dimitar-medium";
const MODEL_PATH =
"bg/bg_BG/dimitar/medium/bg_BG-dimitar-medium.onnx";
const MODEL_BASE =
"https://huggingface.co/rhasspy/piper-voices/resolve/main/";
const mic = document.getElementById("mic");
const stopButton = document.getElementById("stop");
const state = document.getElementById("state");
const hint = document.getElementById("hint");
const permissionScreen = document.getElementById("permissionScreen");
const permissionTitle = document.getElementById("permissionTitle");
const permissionDescription = document.getElementById("permissionDescription");
const permissionButton = document.getElementById("permissionButton");
const permissionMessage = document.getElementById("permissionMessage");
const downloadBar = document.getElementById("downloadBar");
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
// ÐÑлгаÑÑки пÑеобÑазÑваÑел на ÑиÑла; заÑежда Ñе паÑалелно Ñ Piper.
let bgNumberWords = null;
const bgNumberWordsReady = import("https://esm.sh/nums2words-bg@1.5.9")
.then((mod) => {
const candidate = mod.default || mod.nums2wordsBG;
if (typeof candidate !== "function") {
throw new Error("Ðе оÑкÑиÑ
ÑÑнкÑиÑÑа nums2wordsBG.");
}
bgNumberWords = candidate;
console.log("[DAN] Bulgarian number converter ready");
return candidate;
})
.catch((error) => {
console.warn("[DAN] Number converter unavailable; digits will be kept:", error);
return null;
});
// ========================================
// СЪСТÐЯÐÐÐ
// ========================================
let Piper = null;
let piperSession = null;
let voiceReady = false;
let microphoneReady = false;
let voiceError = null;
let microphoneError = null;
let preloadPromise = null;
let recognition = null;
let listening = false;
let speaking = false;
let waiting = false;
let sessionActive = false;
let currentAudio = null;
let finishAudio = null;
let activeController = null;
let history = [];
// ========================================
// ÐСТÐÐ ÐЯ
// ========================================
try {
const saved = JSON.parse(
sessionStorage.getItem("biggest_dan_voice_history") || "[]"
);
history = Array.isArray(saved) ? saved.slice(-20) : [];
} catch (_) {
history = [];
}
function saveHistory() {
try {
sessionStorage.setItem(
"biggest_dan_voice_history",
JSON.stringify(history.slice(-20))
);
} catch (_) {}
}
// ========================================
// СТÐТУС
// ========================================
function setState(name, message) {
document.body.classList.remove("listening", "speaking");
if (name === "СÐУШÐÐ") {
document.body.classList.add("listening");
}
if (name === "ÐÐÐÐРЯ") {
document.body.classList.add("speaking");
}
state.textContent = name;
hint.textContent = message;
}
function refreshScreen() {
mic.disabled = true;
permissionScreen.hidden = false;
if (!voiceReady) {
permissionTitle.textContent =
voiceError ? "ÐÐ ÐШÐÐ ÐÐ Ð ÐÐÐСÐ" : "ÐÐÐÐÐТÐЯРDAN";
permissionDescription.textContent =
"ÐодгоÑвÑм бÑлгаÑÑÐºÐ¸Ñ Ð³Ð»Ð°Ñ ÐимиÑÑÑ. ÐоделÑÑ Ðµ около 63 MB.";
permissionMessage.textContent =
voiceError
? voiceError.message
: "ÐзÑеглÑм и запазвам модела на ÑÑÑÑойÑÑвоÑо...";
permissionButton.hidden = !voiceError;
permissionButton.textContent = "ÐÐÐТÐÐ Ð ÐÐТÐÐÐЯÐÐТÐ";
setState(
voiceError ? "ÐÐ ÐШÐРС ÐÐÐСÐ" : "ÐÐТÐÐÐЯРÐÐÐСÐ",
voiceError ? "Ðе ÑÑпÑÑ
да заÑÐµÐ´Ñ Piper." : "ÐимиÑÑÑ Ñе подгоÑвÑ..."
);
return;
}
if (!microphoneReady) {
permissionTitle.textContent = "ÐÐÐÐ ÐФÐÐ";
permissionDescription.textContent =
"ÐлаÑÑÑ ÐимиÑÑÑ Ðµ гоÑов. Сега ÑазÑеÑи доÑÑÑп до микÑоÑона.";
permissionMessage.textContent =
microphoneError || "ÐзбеÑи âРазÑеÑаванеâ в Chrome.";
permissionButton.hidden = false;
permissionButton.textContent = "Ð ÐÐÐ ÐШРÐÐÐÐ ÐФÐÐÐ";
setState("Ð ÐÐÐ ÐШРÐÐÐÐ ÐФÐÐÐ", "ÐоделÑÑ Ðµ гоÑов.");
return;
}
permissionScreen.hidden = true;
permissionButton.hidden = true;
if (!SpeechRecognition) {
setState(
"ÐРСРÐÐÐÐЪРÐÐ",
"Ðзползвай акÑÑален Google Chrome."
);
return;
}
mic.disabled = false;
setState("ÐÐТÐÐ", "ÐимиÑÑÑ Ðµ заÑеден. ÐаÑиÑни Ð³Ð¾Ð»ÐµÐ¼Ð¸Ñ Ð±ÑÑон.");
}
// ========================================
// PIPER ÐÐÐÐÐÐТÐÐÐ
// ========================================
async function loadPiper() {
const module = await import(
"https://esm.sh/@jtsage/piper-tts-web@1.2.0"
);
console.log("[DAN] Piper exports:", Object.keys(module));
if (
typeof module.TtsSession !== "function" ||
typeof module.CachedFileReader !== "function" ||
!module.PATH_MAP || typeof module.PATH_MAP !== "object"
) {
throw new Error(
"Piper пакеÑÑÑ Ð½Ñма оÑакваниÑе екÑпоÑÑи. ÐалиÑни: " +
Object.keys(module).join(", ")
);
}
// ÐÐÐÐÐ: TtsSession пÑовеÑÑва Ð³Ð»Ð¾Ð±Ð°Ð»Ð½Ð¸Ñ PATH_MAP на пакеÑа,
// пÑеди да използва CachedFileReader. РегиÑÑÑиÑаме Dimitar Ñам.
try {
module.PATH_MAP[VOICE_ID] = MODEL_PATH;
} catch (error) {
throw new Error(
"Ðе мога да Ð´Ð¾Ð±Ð°Ð²Ñ Ð±ÑлгаÑÑÐºÐ¸Ñ Ð³Ð»Ð°Ñ ÐºÑм Piper PATH_MAP: " +
error.message
);
}
if (module.PATH_MAP[VOICE_ID] !== MODEL_PATH) {
throw new Error(
"Piper PATH_MAP не пÑие пÑÑÑ Ð·Ð° " + VOICE_ID
);
}
Piper = module;
console.log("[DAN] Registered voice path:", Piper.PATH_MAP[VOICE_ID]);
}
// ========================================
// СÐÐЦÐÐÐÐÐ ÐÐРТРÐÐ ÐЪÐÐÐРСÐÐЯ ÐÐÐÐÐ
// ========================================
function createPiperFileReader() {
// ÐÑделна каÑÑа за CachedFileReader плÑÑ ÑегиÑÑÑаÑÐ¸Ñ Ð² Piper.PATH_MAP.
const pathMap = {
[VOICE_ID]: MODEL_PATH
};
const expectedFiles = new Set([
MODEL_PATH,
MODEL_PATH + ".json"
]);
console.log("[DAN] Custom Piper PATH_MAP:", pathMap);
return new Piper.CachedFileReader({
pathMap,
// ÐиблиоÑекаÑа може да генеÑиÑа URL кÑм diffusionstudio.
// ÐÑенаÑоÑваме Ñайла по оÑноÑиÑÐµÐ»Ð½Ð¸Ñ Ð¿ÑÑ ÐºÑм ÑеалноÑо rhasspy Ñ
ÑанилиÑе.
customLoader: async (url) => {
const incoming = String(url);
let pathname = incoming;
try {
pathname = new URL(incoming, MODEL_BASE).pathname;
} catch (_) {}
pathname = decodeURIComponent(pathname);
const marker = "/resolve/main/";
const markerIndex = pathname.indexOf(marker);
let relativePath = markerIndex >= 0
? pathname.slice(markerIndex + marker.length)
: pathname.replace(/^\/+/, "");
// Ðапазваме Ñамо дваÑа Ñайла за конкÑеÑÐ½Ð¸Ñ Ð¼Ð¾Ð´ÐµÐ».
// Това не позволÑва неоÑаквани пÑÑиÑа да бÑÐ´Ð°Ñ Ð¸Ð·ÑеглÑни.
if (!expectedFiles.has(relativePath)) {
if (relativePath.endsWith("bg_BG-dimitar-medium.onnx.json")) {
relativePath = MODEL_PATH + ".json";
} else if (relativePath.endsWith("bg_BG-dimitar-medium.onnx")) {
relativePath = MODEL_PATH;
} else {
throw new Error(
"Piper поиÑка Ð½ÐµÐ¿Ð¾Ð·Ð½Ð°Ñ Ñайл: " + incoming
);
}
}
const finalUrl = MODEL_BASE + relativePath;
console.log("[DAN] Correct Piper URL:", finalUrl);
const response = await fetch(finalUrl, {
method: "GET",
mode: "cors",
cache: "force-cache"
});
if (!response.ok) {
throw new Error(
"Piper download HTTP " + response.status + ": " + finalUrl
);
}
// ЧеÑем поÑока, за да показваме Ñеален пÑогÑÐµÑ Ð½Ð° Ð³Ð¾Ð»ÐµÐ¼Ð¸Ñ ONNX Ñайл.
if (!response.body || !response.body.getReader) {
const blob = await response.blob();
if (!blob.size) throw new Error("Piper вÑÑна пÑазен Ñайл: " + relativePath);
return blob;
}
const reader = response.body.getReader();
const chunks = [];
let loaded = 0;
const headerTotal = Number(response.headers.get("content-length")) || 0;
const expectedTotal = relativePath.endsWith(".onnx.json")
? 5123
: 63221984;
const total = headerTotal || expectedTotal;
while (true) {
const result = await reader.read();
if (result.done) break;
chunks.push(result.value);
loaded += result.value.byteLength;
if (relativePath.endsWith(".onnx")) {
const percent = Math.min(99, Math.round(loaded / total * 100));
downloadBar.style.width = percent + "%";
const message = "ÐзÑеглÑм ÐимиÑÑÑ: " + percent + "% (" +
(loaded / 1048576).toFixed(1) + " MB)";
permissionMessage.textContent = message;
setState("ÐÐТÐÐÐЯР" + percent + "%", message);
} else {
permissionMessage.textContent = "ÐзÑеглÑм конÑигÑÑаÑиÑÑа на глаÑа...";
}
}
const blob = new Blob(chunks, {
type: response.headers.get("content-type") || "application/octet-stream"
});
if (!blob.size) {
throw new Error("Piper вÑÑна пÑазен Ñайл: " + relativePath);
}
console.log(
"[DAN] Piper file downloaded:",
relativePath,
blob.size,
"bytes"
);
return blob;
}
});
}
// ========================================
// ÐÐ ÐÐÐÐÐ ÐТÐÐÐÐ ÐÐТÐÐÐЯÐÐ Ð ÐÐÐ ÐÐÐÐÐÐ
// ========================================
async function preloadPiper() {
if (voiceReady) return;
if (preloadPromise) return preloadPromise;
preloadPromise = (async () => {
voiceError = null;
refreshScreen();
try {
if (!Piper) {
await loadPiper();
}
setState(
"ÐÐÐ ÐÐÐÐÐ ÐÐÐÐТЪР",
"ÐзÑеглÑм бÑлгаÑÑÐºÐ¸Ñ Ð¼Ð¾Ð´ÐµÐ»..."
);
permissionMessage.textContent =
"ÐодгоÑвÑм глаÑа за пÑÑва ÑпоÑÑеба. ÐзÑакай.";
downloadBar.style.width = "0%";
// Voice ID веÑе е ÑегиÑÑÑиÑан в Piper.PATH_MAP.
// CachedFileReader пÑенаÑоÑва ÑайловеÑе кÑм rhasspy/piper-voices.
const fileReader = createPiperFileReader();
piperSession = new Piper.TtsSession({
voiceId: VOICE_ID,
fileReader: fileReader,
allowLocalModels: true,
fallbackStrategy: "cdn",
progress: (progress) => {
const loaded = Number(progress.loaded) || 0;
const total = Number(progress.total) || 0;
if (total > 0) {
const percent = Math.min(
100,
Math.round(loaded * 100 / total)
);
downloadBar.style.width = percent + "%";
const loadedMB = (loaded / 1048576).toFixed(1);
const totalMB = (total / 1048576).toFixed(1);
const message =
"ÐзÑеглÑм ÐимиÑÑÑ: " +
percent + "% (" +
loadedMB + " / " + totalMB + " MB)";
permissionMessage.textContent = message;
setState("ÐÐТÐÐÐЯР" + percent + "%", message);
} else {
permissionMessage.textContent =
"ÐаÑеждам и кеÑиÑам бÑлгаÑÑÐºÐ¸Ñ Ð¼Ð¾Ð´ÐµÐ»...";
}
},
logger: (message) => {
console.log("[PIPER]", message);
}
});
// ÐÑÑвиÑÑ predict заÑежда модела в OPFS
// и подгоÑÐ²Ñ Ð´Ð²Ð¸Ð³Ð°ÑÐµÐ»Ñ Ð·Ð° ÑледваÑиÑе оÑговоÑи.
setState(
"ÐÐÐÐÐТÐЯРÐÐÐ",
"ÐаÑеждам модела в локалноÑо Ñ
ÑанилиÑе..."
);
const testAudio = await piperSession.predict(
"ÐдÑавей! Ðз ÑÑм глаÑÑÑ Ð½Ð° THE BIGGEST DAN."
);
if (!testAudio || testAudio.size === 0) {
throw new Error("Piper не генеÑиÑа ÑеÑÑово аÑдио.");
}
voiceReady = true;
voiceError = null;
downloadBar.style.width = "100%";
console.log("[DAN] Piper ready:", VOICE_ID);
refreshScreen();
} catch (error) {
console.error("[DAN] Piper initialization failed:", error);
piperSession = null;
voiceReady = false;
voiceError = error;
refreshScreen();
}
})();
try {
await preloadPromise;
} finally {
preloadPromise = null;
}
}
// ========================================
// ÐÐÐÐ ÐФÐÐ
// ========================================
async function requestMicrophonePermission() {
if (!navigator.mediaDevices?.getUserMedia) {
microphoneError =
"ÐÑвоÑи ÑайÑа пÑез HTTPS в акÑÑален Chrome.";
refreshScreen();
return false;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true
});
stream.getTracks().forEach(track => track.stop());
microphoneReady = true;
microphoneError = null;
refreshScreen();
return true;
} catch (error) {
console.error("[DAN] Microphone error:", error);
microphoneReady = false;
microphoneError =
error.name === "NotAllowedError"
? "Chrome оÑказа доÑÑÑп. ÐÑовеÑи ÑазÑеÑениÑÑа за микÑоÑона."
: error.message || "Ðе ÑÑпÑÑ
да полÑÑа доÑÑÑп до микÑоÑона.";
refreshScreen();
return false;
}
}
async function initializeMicrophone() {
try {
if (navigator.permissions?.query) {
const permission = await navigator.permissions.query({
name: "microphone"
});
if (permission.state === "granted") {
microphoneReady = true;
microphoneError = null;
refreshScreen();
return;
}
if (permission.state === "denied") {
microphoneError = "ÐикÑоÑонÑÑ Ðµ блокиÑан за Ñози ÑайÑ.";
refreshScreen();
return;
}
}
} catch (_) {}
await requestMicrophonePermission();
}
// ========================================
// ÐÐÐТÐÐ ÐÐ ÐÐÐТ
// ========================================
permissionButton.addEventListener("click", async () => {
if (!voiceReady) {
Piper = null;
piperSession = null;
await preloadPiper();
} else {
await requestMicrophonePermission();
}
});
// ========================================
// Ð ÐÐÐÐÐЯÐÐ ÐÐ ÐЪÐÐÐ ÐТÐÐÐÐÐ Ð
// ========================================
function cleanTextForSpeech(input) {
let text = String(input ?? "");
// Remove code blocks, but keep inline code as readable text.
text = text.replace(/```[\s\S]*?```/g, " ");
text = text.replace(/`([^`]+)`/g, "$1");
// Speak link labels, not URLs.
text = text.replace(/\[([^\]]+)\]\((?:[^)]+)\)/g, "$1");
text = text.replace(/https?:\/\/\S+/gi, " ");
// Remove Markdown headings, quotes, list markers and emphasis.
text = text.replace(/^\s{0,3}#{1,6}\s*/gm, "");
text = text.replace(/^\s{0,3}>\s?/gm, "");
text = text.replace(/^\s*(?:[-+*]|\d+[.)])\s+/gm, "");
text = text.replace(/\*\*|__|~~/g, "");
text = text.replace(/[*_~]/g, "");
// Strip HTML and Markdown table separators.
text = text.replace(/<[^>]*>/g, " ");
text = text.replace(/\|/g, ", ");
// Remove common decorative symbols that should not be spoken.
text = text.replace(/[â
ââ¢âªââ¶âº]/g, " ");
text = text.replace(/ /gi, " ");
text = text.replace(/&/gi, "и");
return text.replace(/\s+/g, " ").trim();
}
function spellNumber(value, options = {}) {
if (typeof bgNumberWords !== "function") return null;
try {
const result = bgNumberWords(String(value), options);
return typeof result === "string" && result.trim()
? result.trim()
: null;
} catch (error) {
console.warn("[DAN] Number conversion failed:", value, error);
return null;
}
}
function convertBulgarianNumbers(input) {
if (typeof bgNumberWords !== "function") return input;
let text = input;
// Currency amounts: use the package's Bulgarian currency formatter.
// It returns the full amount with currency units in words.
if (typeof bgNumberWords.currency === "function") {
text = text.replace(
/(?<![\p{L}\d])([0-9]+(?:[.,][0-9]{1,2})?)\s*(лв\.?|лева|лев|BGN)(?![\p{L}])/giu,
(match, amount) => {
try {
return bgNumberWords.currency(amount.replace(",", "."));
} catch (_) {
return match;
}
}
);
text = text.replace(
/(?<![\p{L}\d])([0-9]+(?:[.,][0-9]{1,2})?)\s*(EUR|â¬)(?![\p{L}])/giu,
(match, amount) => {
try {
return bgNumberWords.currency(amount.replace(",", "."), {
currency: "EUR"
});
} catch (_) {
return match;
}
}
);
text = text.replace(
/(?<![\p{L}\d])([0-9]+(?:[.,][0-9]{1,2})?)\s*(USD|\$)(?![\p{L}])/giu,
(match, amount) => {
try {
return bgNumberWords.currency(amount.replace(",", "."), {
currency: "USD"
});
} catch (_) {
return match;
}
}
);
}
// Percentages: spell the numeric part and say the unit naturally.
text = text.replace(
/(?<![\p{L}\d])([0-9]{1,9})\s*%(?![\p{L}])/gu,
(match, digits) => {
const words = spellNumber(digits, { gender: "m" });
if (!words) return match;
return words + (digits === "1" ? " пÑоÑенÑ" : " пÑоÑенÑа");
}
);
// Convert ordinary integers. Avoid dates, decimals, version numbers,
// phone numbers and long IDs because their reading needs context.
const integerPattern = /(?<![\p{L}\d.,/:-])((?:\d{1,3}(?:[ \u00A0]\d{3})+)|\d{1,9})(?![\p{L}\d.,/:-])/gu;
text = text.replace(integerPattern, (original) => {
const digits = original.replace(/[ \u00A0]/g, "");
if (!/^\d{1,9}$/.test(digits)) return original;
return spellNumber(digits) || original;
});
return text;
}
async function prepareSpeechText(input) {
const cleaned = cleanTextForSpeech(input);
await bgNumberWordsReady;
return convertBulgarianNumbers(cleaned);
}
function splitText(text, maxLength = 420) {
const sentences = text.match(/[^.!?â¦]+[.!?â¦]*/g) || [text];
const chunks = [];
let current = "";
for (const sentence of sentences) {
const clean = sentence.trim();
if (!clean) continue;
if (clean.length > maxLength) {
if (current) {
chunks.push(current);
current = "";
}
const words = clean.split(/\s+/);
let part = "";
for (const word of words) {
const candidate = part ? part + " " + word : word;
if (candidate.length > maxLength && part) {
chunks.push(part);
part = word;
} else {
part = candidate;
}
}
if (part) chunks.push(part);
continue;
}
const candidate = current ? current + " " + clean : clean;
if (candidate.length > maxLength && current) {
chunks.push(current);
current = clean;
} else {
current = candidate;
}
}
if (current) chunks.push(current);
return chunks;
}
// ========================================
// СÐÐÐ ÐÐÐ ÐÐ ÐУÐÐÐ
// ========================================
function stopAudio() {
if (currentAudio) {
currentAudio.pause();
}
if (finishAudio) {
const finish = finishAudio;
finishAudio = null;
finish();
}
}
// ========================================
// ÐЪÐÐÐ ÐÐÐÐÐÐÐÐÐÐ ÐÐ PIPER
// ========================================
function playPiperAudio(blob, onPlaying) {
return new Promise((resolve, reject) => {
try {
stopAudio();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
currentAudio = audio;
speaking = true;
// Slightly quicker delivery without changing the Piper model.
// Supported browsers preserve pitch; unsupported ones ignore the property.
audio.playbackRate = 1.10;
if ("preservesPitch" in audio) audio.preservesPitch = true;
setState("ÐÐÐÐРЯ", "THE BIGGEST DAN Ñи оÑговаÑÑ...");
let finished = false;
function finish(error) {
if (finished) return;
finished = true;
audio.onended = null;
audio.onplaying = null;
audio.onerror = null;
audio.pause();
audio.src = "";
URL.revokeObjectURL(url);
if (currentAudio === audio) {
currentAudio = null;
}
if (finishAudio === finish) {
finishAudio = null;
}
speaking = false;
if (error) reject(error);
else resolve();
}
finishAudio = finish;
let playbackStarted = false;
audio.onplaying = () => {
if (playbackStarted) return;
playbackStarted = true;
if (typeof onPlaying === "function") onPlaying();
};
audio.onended = () => finish();
audio.onerror = () => {
finish(new Error("ÐÑоблем пÑи вÑзпÑоизвеждане на Piper аÑдиоÑо."));
};
audio.play().catch(error => {
finish(new Error("ÐÑаÑзÑÑÑÑ Ð¾Ñказа аÑдиоÑо: " + error.message));
});
} catch (error) {
speaking = false;
reject(error);
}
});
}
async function speakWithPiper(text) {
if (!voiceReady || !piperSession) {
throw new Error("ÐлаÑÑÑ ÐимиÑÑÑ Ð¾Ñе не е гоÑов.");
}
const chunks = splitText(text, 420);
let pendingAudio = piperSession.predict(chunks[0]);
for (let i = 0; i < chunks.length; i++) {
if (!sessionActive) break;
setState(
"ÐÐÐÐРЯ",
chunks.length > 1
? "ÐовоÑÑ... ЧаÑÑ " + (i + 1) + " Ð¾Ñ " + chunks.length
: "THE BIGGEST DAN Ñи оÑговаÑÑ..."
);
const synthesisStarted = performance.now();
const wav = await pendingAudio;
console.log(
"[DAN] Piper synthesis chunk " + (i + 1) + "/" + chunks.length +
" took " + Math.round(performance.now() - synthesisStarted) + " ms"
);
if (!sessionActive) break;
let nextAudioPromise = null;
// Start preparing the next chunk only after the current audio has
// actually started playing. This overlaps local synthesis with playback
// and reduces the silent gaps between long answers.
await playPiperAudio(wav, () => {
if (i + 1 < chunks.length && sessionActive && !nextAudioPromise) {
nextAudioPromise = piperSession.predict(chunks[i + 1]);
// Attach a handler now so a fast rejection is not unhandled.
nextAudioPromise.catch(() => {});
}
});
if (i + 1 < chunks.length && sessionActive) {
pendingAudio = nextAudioPromise || piperSession.predict(chunks[i + 1]);
}
}
}
// ========================================
// GROQ / QWEN
// ========================================
async function askAI(message) {
waiting = true;
setState("ÐÐСÐЯ", "THE BIGGEST DAN подгоÑÐ²Ñ Ð¾ÑговоÑ...");
const controller = new AbortController();
activeController = controller;
try {
const groqStarted = performance.now();
const response = await fetch(API, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
signal: controller.signal,
body: JSON.stringify({
message,
history: history.slice(-10)
})
});
let data;
try {
data = await response.json();
} catch (_) {
throw new Error("Worker вÑÑна невалиден оÑговоÑ.");
}
if (!response.ok) {
throw new Error(
data.details || data.error || "ÐÑеÑка пÑи вÑÑзкаÑа Ñ Groq."
);
}
if (typeof data.answer !== "string" || !data.answer.trim()) {
throw new Error("Qwen не вÑÑна ÑекÑÑов оÑговоÑ.");
}
const groqMs = Math.round(performance.now() - groqStarted);
console.log("[DAN] Groq request completed in " + groqMs + " ms");
history.push(
{ role: "user", text: message },
{ role: "assistant", text: data.answer }
);
saveHistory();
waiting = false;
if (activeController === controller) {
activeController = null;
}
if (!sessionActive) return;
// Remove Markdown symbols and spell numbers in Bulgarian before Piper.
const speechText = await prepareSpeechText(data.answer);
console.log("[DAN] Clean speech text:", speechText);
setState(
"ÐÐÐÐÐТÐЯРÐÐÐСÐ",
"ÐÑговоÑÑÑ Ðµ гоÑов. ÐодгоÑвÑм озвÑÑаванеÑо..."
);
await speakWithPiper(speechText);
if (sessionActive) {
startListening();
}
} catch (error) {
waiting = false;
if (activeController === controller) {
activeController = null;
}
if (error.name === "AbortError") return;
console.error("[DAN]", error);
if (sessionActive) {
setState("ÐÐ ÐШÐÐ", error.message || "ÐÑзникна пÑоблем.");
}
}
}
// ========================================
// ÐÐÐСÐÐÐ Ð ÐÐÐÐÐÐÐÐÐÐÐ
// ========================================
function startListening() {
if (!sessionActive || listening || speaking || waiting) return;
if (!voiceReady || !microphoneReady) {
sessionActive = false;
refreshScreen();
return;
}
if (!SpeechRecognition) {
sessionActive = false;
setState("ÐРСРÐÐÐÐЪРÐÐ", "Ðзползвай акÑÑален Google Chrome.");
return;
}
const instance = new SpeechRecognition();
recognition = instance;
instance.lang = "bg-BG";
instance.continuous = false;
instance.interimResults = false;
instance.maxAlternatives = 1;
instance.onstart = () => {
if (recognition !== instance) return;
listening = true;
setState("СÐУШÐÐ", "ÐовоÑи Ñпокойно...");
};
instance.onresult = async event => {
if (recognition !== instance) return;
const transcript =
event.results?.[0]?.[0]?.transcript?.trim() || "";
listening = false;
if (transcript) {
setState("ÐÐСÐЯ", "ÐолÑÑиÑ
ÑÑобÑениеÑо Ñи...");
await askAI(transcript);
} else {
sessionActive = false;
setState("ÐРЧУХ", "Ðе ÑÑпÑÑ
да ÑазбеÑа. ÐпиÑай оÑново.");
}
};
instance.onerror = event => {
if (recognition !== instance) return;
listening = false;
sessionActive = false;
console.error("[DAN] Recognition error:", event.error);
if (event.error === "not-allowed") {
microphoneReady = false;
microphoneError = "Chrome оÑказа доÑÑÑп до микÑоÑона.";
refreshScreen();
} else if (event.error === "no-speech") {
setState("ÐРЧУХ", "Ðе ÑÑÑ
глаÑ. ÐпиÑай оÑново.");
} else {
setState("ÐÐ ÐШÐÐ", "ÐÑоблем Ñ Ð¼Ð¸ÐºÑоÑона или ÑазпознаванеÑо.");
}
};
instance.onend = () => {
if (recognition !== instance) return;
listening = false;
if (sessionActive && !speaking && !waiting) {
sessionActive = false;
setState("ÐÐТÐÐ", "ÐаÑиÑни бÑÑона, за да пÑодÑлжиÑ.");
}
};
try {
instance.start();
} catch (error) {
listening = false;
sessionActive = false;
console.error(error);
setState("ÐÐ ÐШÐÐ", "Ðе ÑÑпÑÑ
да ÑÑаÑÑиÑам микÑоÑона.");
}
}
// ========================================
// ÐСÐÐÐÐÐ ÐУТÐÐ
// ========================================
mic.addEventListener("click", () => {
if (!voiceReady || !microphoneReady || waiting) return;
if (listening && recognition) {
sessionActive = false;
try {
recognition.stop();
} catch (_) {}
listening = false;
setState("ÐÐТÐÐ", "СлÑÑанеÑо е ÑпÑÑно.");
return;
}
if (speaking) {
stopAudio();
}
sessionActive = true;
startListening();
});
// ========================================
// ÐÐ ÐÐÐЮЧÐÐÐÐ
// ========================================
stopButton.addEventListener("click", () => {
sessionActive = false;
if (recognition) {
try {
recognition.abort();
} catch (_) {}
recognition = null;
}
if (activeController) {
activeController.abort();
activeController = null;
}
stopAudio();
listening = false;
speaking = false;
waiting = false;
if (voiceReady && microphoneReady) {
setState("ÐÐТÐÐ", "РазговоÑÑÑ Ð¿ÑиклÑÑи.");
} else {
refreshScreen();
}
});
// ========================================
// СТÐРТÐÐ ÐÐÐ
// ========================================
initializeMicrophone();
preloadPiper();
// Number conversion is optional and loads in parallel with Piper.
bgNumberWordsReady.catch(() => {});
</script>
</body>
</html>