fix(voice): continuous=false + auto-restart — eliminates word duplication

Replaced continuous=true with phrase-by-phrase mode in all 3 voice inputs
(setupVoiceMicForField, setupVoiceInput, setupVoiceMic). Chrome mobile
resets ev.results mid-session with continuous=true causing repeated words.
Now each phrase is an independent SR() session; baseText grows cleanly.
Shared _buildVoiceEngine factory in clients.js.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-14 14:06:48 +03:00
parent 61f23c9bca
commit dd8691671a
3 changed files with 139 additions and 175 deletions

View File

@ -205,77 +205,103 @@ const Clients = (function () {
return { ok: true, value: "+" + normalized };
}
function setupVoiceMicForField(micBtn, textarea, statusEl) {
if (!micBtn || !textarea) return;
// Единая фабрика голосового ввода.
// continuous=false + авто-рестарт по фразам — исключает дубли, стабильно на Android/iOS.
function _buildVoiceEngine(micBtn, textarea, opts) {
// opts: { statusEl, statusClass, onChange }
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) {
micBtn.disabled = true;
micBtn.title = "Браузер не поддерживает голос";
micBtn.style.opacity = "0.5";
if (statusEl) statusEl.textContent = "недоступно";
if (opts.statusEl) opts.statusEl.textContent = "недоступно";
return;
}
let rec = null, recording = false;
let baseText = ""; // текст до начала записи
let confirmedFinal = ""; // финальные части накопленные в этой сессии записи
micBtn.addEventListener("click", () => {
if (recording) { rec?.stop(); return; }
let active = false; // пользователь включил микрофон
let baseText = ""; // подтверждённый текст (растёт по фразам)
let curRec = null;
function _setStatus(txt, cls) {
if (!opts.statusEl) return;
opts.statusEl.textContent = txt;
if (opts.statusClass && cls) opts.statusEl.className = opts.statusClass + (cls !== "ok" ? " " + cls : "");
}
function startPhrase() {
let rec;
try {
rec = new SR();
rec.lang = "ru-RU"; rec.continuous = true; rec.interimResults = true;
rec.lang = "ru-RU";
rec.continuous = false; // одна фраза — один сеанс, нет накопленных results
rec.interimResults = true;
} catch (e) {
if (statusEl) statusEl.textContent = "Микрофон недоступен";
_setStatus("Микрофон недоступен", "err");
active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать";
return;
}
baseText = (textarea.value || "").trim();
confirmedFinal = "";
curRec = rec;
rec.onstart = () => {
recording = true;
rec.onresult = (ev) => {
// Только результаты ЭТОЙ фразы — ev.results всегда свежий (continuous=false)
let fin = "", itr = "";
for (let i = 0; i < ev.results.length; i++) {
const t = ev.results[i][0].transcript.trim();
if (!t) continue;
if (ev.results[i].isFinal) fin += (fin ? " " : "") + t;
else itr += (itr ? " " : "") + t;
}
const shown = fin || itr;
textarea.value = baseText + (baseText && shown ? " " : "") + shown;
};
rec.onend = () => {
// Зафиксировать текущий текст как base и запустить следующую фразу (если active)
baseText = textarea.value.trim();
if (active) {
startPhrase();
} else {
micBtn.classList.remove("rec");
micBtn.textContent = "🎤 Диктовать";
_setStatus("", "ok");
if (opts.onChange) opts.onChange(textarea.value || "");
haptic && haptic("impact");
}
};
rec.onerror = (ev) => {
if (ev.error === "no-speech") return; // тишина — onend сработает, авто-перезапуск
_setStatus("Ошибка: " + (ev.error || ""), "err");
active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать";
};
try { rec.start(); }
catch (e) {
_setStatus("Не запустить: " + e.message, "err");
active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать";
}
}
micBtn.addEventListener("click", () => {
if (active) {
active = false;
curRec?.stop(); // onend → видит active=false → сбросит кнопку
return;
}
active = true;
baseText = (textarea.value || "").trim();
micBtn.classList.add("rec");
micBtn.textContent = "⏹ Стоп";
if (statusEl) statusEl.textContent = "Слушаю...";
_setStatus("Слушаю...", "ok");
haptic && haptic("impact");
};
rec.onresult = (ev) => {
// Пересчитываем ВСЕ финальные и interim с нуля каждый раз — гарантия от дублей
let finalAll = "";
let interim = "";
for (let i = 0; i < ev.results.length; i++) {
const t = ev.results[i][0].transcript;
if (ev.results[i].isFinal) finalAll += t;
else interim += t;
}
confirmedFinal = finalAll.trim();
const finalPart = confirmedFinal ? (baseText ? " " : "") + confirmedFinal : "";
const interimPart = interim.trim() ? ((baseText || confirmedFinal) ? " " : "") + interim.trim() : "";
textarea.value = baseText + finalPart + interimPart;
};
rec.onerror = (ev) => {
if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || "");
recording = false;
micBtn.classList.remove("rec");
micBtn.textContent = "🎤 Диктовать";
};
rec.onend = () => {
recording = false;
micBtn.classList.remove("rec");
micBtn.textContent = "🎤 Диктовать";
// Фиксируем итоговый текст: baseText + final
if (confirmedFinal) {
baseText = (baseText + (baseText ? " " : "") + confirmedFinal).trim();
textarea.value = baseText;
}
if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = "";
haptic && haptic("impact");
};
try { rec.start(); } catch (e) {
if (statusEl) statusEl.textContent = "Не запустить: " + e.message;
}
startPhrase();
});
}
function setupVoiceMicForField(micBtn, textarea, statusEl) {
_buildVoiceEngine(micBtn, textarea, { statusEl });
}
/* ===================== Список клиентов ===================== */
async function renderList() {
@ -1263,81 +1289,9 @@ const Clients = (function () {
}
function setupVoiceInput(micBtn, textarea, status) {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) {
micBtn.disabled = true;
micBtn.title = "Браузер не поддерживает голосовой ввод";
micBtn.style.opacity = "0.5";
return;
}
let rec = null;
let recording = false;
let baseText = ""; // что было в textarea ДО старта записи
let confirmedFinal = ""; // финальная фраза текущей сессии записи
micBtn.addEventListener("click", () => {
if (recording) { rec?.stop(); return; }
try {
rec = new SR();
rec.lang = "ru-RU";
rec.continuous = true;
rec.interimResults = true;
} catch (e) {
status.textContent = "Микрофон недоступен: " + e.message;
status.className = "note-status err";
return;
}
baseText = (textarea.value || "").trim();
confirmedFinal = "";
rec.onstart = () => {
recording = true;
micBtn.classList.add("rec");
micBtn.textContent = "⏹ Стоп";
status.textContent = "Слушаю...";
status.className = "note-status";
haptic && haptic("impact");
};
// Защита от дублей: пересчитываем ВСЕ финальные и interim с нуля
// на каждом событии. Не полагаемся на ev.resultIndex (в Chrome он
// ведёт себя нестабильно при паузах — отсюда дублирование слов).
rec.onresult = (ev) => {
let finalAll = "", interim = "";
for (let i = 0; i < ev.results.length; i++) {
const t = ev.results[i][0].transcript;
if (ev.results[i].isFinal) finalAll += t;
else interim += t;
}
confirmedFinal = finalAll.trim();
const sep = baseText ? " " : "";
const fp = confirmedFinal ? sep + confirmedFinal : "";
const ip = interim.trim() ? ((baseText || confirmedFinal) ? " " : "") + interim.trim() : "";
textarea.value = baseText + fp + ip;
};
rec.onerror = (ev) => {
status.textContent = "Ошибка распознавания: " + (ev.error || "неизвестно");
status.className = "note-status err";
recording = false;
micBtn.classList.remove("rec");
micBtn.textContent = "🎤 Диктовать";
};
rec.onend = () => {
recording = false;
micBtn.classList.remove("rec");
micBtn.textContent = "🎤 Диктовать";
if (status.textContent === "Слушаю...") status.textContent = "";
// Фиксируем подтверждённый текст в baseText на случай повторного запуска
if (confirmedFinal) {
baseText = (baseText + (baseText ? " " : "") + confirmedFinal).trim();
textarea.value = baseText;
}
haptic && haptic("impact");
};
try { rec.start(); }
catch (e) {
status.textContent = "Не запустить: " + e.message;
status.className = "note-status err";
}
_buildVoiceEngine(micBtn, textarea, {
statusEl: status,
statusClass: "note-status",
});
}

View File

@ -243,7 +243,8 @@ const Measurements = (function () {
return node;
}
/* ===================== Голосовой ввод заметок (без дублей) ===================== */
/* ===================== Голосовой ввод заметок ===================== */
// continuous=false + авто-рестарт по фразам — исключает дубли на Android/iOS Chrome
function setupVoiceMic(micBtn, textarea, statusEl, onChange) {
if (!micBtn || !textarea) return;
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
@ -254,66 +255,75 @@ const Measurements = (function () {
if (statusEl) statusEl.textContent = "недоступно в этом браузере";
return;
}
let rec = null;
let recording = false;
let baseText = "";
let confirmedFinal = "";
micBtn.addEventListener("click", () => {
if (recording) { rec?.stop(); return; }
let active = false;
let baseText = "";
let curRec = null;
function startPhrase() {
let rec;
try {
rec = new SR();
rec.lang = "ru-RU";
rec.continuous = true;
rec.continuous = false;
rec.interimResults = true;
} catch (e) {
if (statusEl) statusEl.textContent = "Микрофон недоступен: " + e.message;
active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать";
return;
}
baseText = (textarea.value || "").trim();
confirmedFinal = "";
curRec = rec;
rec.onstart = () => {
recording = true;
rec.onresult = (ev) => {
let fin = "", itr = "";
for (let i = 0; i < ev.results.length; i++) {
const t = ev.results[i][0].transcript.trim();
if (!t) continue;
if (ev.results[i].isFinal) fin += (fin ? " " : "") + t;
else itr += (itr ? " " : "") + t;
}
const shown = fin || itr;
textarea.value = baseText + (baseText && shown ? " " : "") + shown;
};
rec.onend = () => {
baseText = textarea.value.trim();
if (active) {
startPhrase();
} else {
micBtn.classList.remove("rec");
micBtn.textContent = "🎤 Диктовать";
if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = "";
if (onChange) onChange(textarea.value || "");
haptic && haptic("impact");
}
};
rec.onerror = (ev) => {
if (ev.error === "no-speech") return;
if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || "неизвестно");
active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать";
};
try { rec.start(); } catch (e) {
if (statusEl) statusEl.textContent = "Не запустить: " + e.message;
active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать";
}
}
micBtn.addEventListener("click", () => {
if (active) {
active = false;
curRec?.stop();
return;
}
active = true;
baseText = (textarea.value || "").trim();
micBtn.classList.add("rec");
micBtn.textContent = "⏹ Стоп";
if (statusEl) statusEl.textContent = "Слушаю...";
haptic && haptic("impact");
};
rec.onresult = (ev) => {
// Пересчёт с нуля каждый раз — гарантия от дублей
let finalAll = "", interim = "";
for (let i = 0; i < ev.results.length; i++) {
const t = ev.results[i][0].transcript;
if (ev.results[i].isFinal) finalAll += t;
else interim += t;
}
confirmedFinal = finalAll.trim();
const fp = confirmedFinal ? (baseText ? " " : "") + confirmedFinal : "";
const ip = interim.trim() ? ((baseText || confirmedFinal) ? " " : "") + interim.trim() : "";
textarea.value = baseText + fp + ip;
};
rec.onerror = (ev) => {
if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || "неизвестно");
recording = false;
micBtn.classList.remove("rec");
micBtn.textContent = "🎤 Диктовать";
};
rec.onend = () => {
recording = false;
micBtn.classList.remove("rec");
micBtn.textContent = "🎤 Диктовать";
if (confirmedFinal) {
baseText = (baseText + (baseText ? " " : "") + confirmedFinal).trim();
textarea.value = baseText;
}
if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = "";
if (onChange) onChange(textarea.value || "");
haptic && haptic("impact");
};
try { rec.start(); } catch (e) {
if (statusEl) statusEl.textContent = "Не запустить: " + e.message;
}
startPhrase();
});
}

View File

@ -12,14 +12,14 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260514f">
<link rel="stylesheet" href="assets/podbor.css?v=20260514f">
<link rel="stylesheet" href="assets/styles.css?v=20260514g">
<link rel="stylesheet" href="assets/podbor.css?v=20260514g">
</head>
<body>
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
<div class="loader splash" id="splash">
<div class="brand-logo-wrap">
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514f" alt="@wasrusgen1">
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514g" alt="@wasrusgen1">
<div class="splash-dust" aria-hidden="true">
<span class="dust d1"></span> <span class="dust d2"></span>
<span class="dust d3"></span> <span class="dust d4"></span>
@ -35,15 +35,15 @@
<div class="brand-tagline-gold">CRM</div>
</div>
<main id="app"></main>
<script src="assets/icons.js?v=20260514f"></script>
<script src="assets/podbor.config.js?v=20260514f"></script>
<script src="assets/podbor.picts.js?v=20260514f"></script>
<script src="assets/podbor.js?v=20260514f"></script>
<script src="assets/clients.js?v=20260514f"></script>
<script src="assets/zamer-picts.js?v=20260514f"></script>
<script src="assets/measurements.js?v=20260514f"></script>
<script src="assets/request.js?v=20260514f"></script>
<script src="assets/assembly.js?v=20260514f"></script>
<script src="assets/app.js?v=20260514f"></script>
<script src="assets/icons.js?v=20260514g"></script>
<script src="assets/podbor.config.js?v=20260514g"></script>
<script src="assets/podbor.picts.js?v=20260514g"></script>
<script src="assets/podbor.js?v=20260514g"></script>
<script src="assets/clients.js?v=20260514g"></script>
<script src="assets/zamer-picts.js?v=20260514g"></script>
<script src="assets/measurements.js?v=20260514g"></script>
<script src="assets/request.js?v=20260514g"></script>
<script src="assets/assembly.js?v=20260514g"></script>
<script src="assets/app.js?v=20260514g"></script>
</body>
</html>