feat(tts): integrate autonomous Piper neural TTS with zero-latency pre-warming for words and context

This commit is contained in:
2026-10-07 10:40:02 +03:00
parent 915d2784a2
commit a6fb4349e9
12 changed files with 514 additions and 27 deletions
+10 -2
View File
@@ -3,7 +3,7 @@
// =======================================================================
import { api } from './api.js';
import { escapeHtml, showToast, openModal, closeModal } from './ui.js';
import { escapeHtml, showToast, openModal, closeModal, playAudio } from './ui.js';
export let allCards = [];
export let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы'];
@@ -163,6 +163,14 @@ export function initDictionary() {
}
});
if (audioPlayBtn) {
audioPlayBtn.addEventListener('click', () => {
if (!currentLookupData || !currentLookupData.term) return;
const url = currentLookupData.audio_url || `/api/tts?text=${encodeURIComponent(currentLookupData.term)}&lang=en`;
playAudio(url);
});
}
async function performLookup() {
const term = termInput.value.trim();
if (!term) return;
@@ -188,7 +196,7 @@ export function initDictionary() {
resPhonetic.textContent = data.phonetic || '';
resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none';
resPos.textContent = data.part_of_speech || 'Phrase';
audioPlayBtn.classList.add('hidden');
if (audioPlayBtn) audioPlayBtn.classList.remove('hidden');
if (data.definition_en) {
resDefinition.textContent = data.definition_en;
+39 -2
View File
@@ -3,7 +3,7 @@
// =======================================================================
import { api } from './api.js';
import { escapeHtml, escapeRegex, slugify, showToast } from './ui.js';
import { escapeHtml, escapeRegex, slugify, showToast, playAudio } from './ui.js';
import { getAllCards, fetchCards } from './dictionary.js';
// Reader State & Appearance Settings
@@ -1454,7 +1454,11 @@ export async function openWordPopup(term, contextSentence, targetElement, paragr
if (popupTranslation) popupTranslation.textContent = trans || 'Перевод не найден';
if (popupLoading) popupLoading.classList.add('hidden');
if (popupContent) popupContent.classList.remove('hidden');
if (popupAudioBtn) popupAudioBtn.classList.add('hidden');
if (popupAudioBtn) {
popupAudioBtn.classList.remove('hidden');
popupAudioBtn.style.display = 'inline-flex';
}
popupCurrentData.audioUrl = data.audio_url || '';
const inLib = Boolean(data.in_library || existingCard);
const libCard = data.library_card || existingCard;
@@ -2096,6 +2100,9 @@ export function initReader() {
window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true });
window.addEventListener('resize', schedulePopupUpdate, { passive: true });
const popupAudioBtn = document.getElementById('popup-audio-btn');
const popupAudioSentenceBtn = document.getElementById('popup-audio-sentence-btn');
const popupAudioRuBtn = document.getElementById('popup-audio-ru-btn');
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
@@ -2105,6 +2112,36 @@ export function initReader() {
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
if (popupAudioBtn) {
popupAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.term) return;
const term = popupCurrentData.term;
const url = popupCurrentData.audioUrl || `/api/tts?text=${encodeURIComponent(term)}&lang=en`;
playAudio(url);
});
}
if (popupAudioSentenceBtn) {
popupAudioSentenceBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
const sentence = popupCurrentData.contextSentence;
const url = `/api/tts?text=${encodeURIComponent(sentence)}&lang=en`;
playAudio(url);
});
}
if (popupAudioRuBtn) {
popupAudioRuBtn.addEventListener('click', (e) => {
e.stopPropagation();
const ruText = (popupSentenceRu && popupSentenceRu.textContent || '').trim();
if (!ruText || ruText.startsWith('(') || ruText.startsWith('Ошибка')) return;
const url = `/api/tts?text=${encodeURIComponent(ruText)}&lang=ru`;
playAudio(url);
});
}
if (popupTranslateSentenceBtn) {
popupTranslateSentenceBtn.addEventListener('click', async (e) => {
e.stopPropagation();
+47 -3
View File
@@ -52,6 +52,7 @@ export function initSrs() {
const fcBackDef = document.getElementById('fc-back-def');
const fcBackContextBox = document.getElementById('fc-back-context-box');
const fcBackContext = document.getElementById('fc-back-context');
const fcBackContextAudioBtn = document.getElementById('fc-back-context-audio-btn');
const fcBackNotesBox = document.getElementById('fc-back-notes-box');
const fcBackNotes = document.getElementById('fc-back-notes');
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
@@ -298,8 +299,41 @@ export function initSrs() {
fcBackNotes.textContent = card.notes || '';
fcBackNotesBox.style.display = card.notes ? 'block' : 'none';
fcFrontAudioBtn.style.display = 'none';
fcBackAudioBtn.style.display = 'none';
if (fcFrontAudioBtn) fcFrontAudioBtn.style.display = 'inline-flex';
if (fcBackAudioBtn) fcBackAudioBtn.style.display = 'inline-flex';
if (fcBackContextAudioBtn) {
fcBackContextAudioBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
}
}
if (fcFrontAudioBtn) {
fcFrontAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
playAudio(url);
});
}
if (fcBackAudioBtn) {
fcBackAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
playAudio(url);
});
}
if (fcBackContextAudioBtn) {
fcBackContextAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card || !card.context_sentence) return;
const url = card.context_audio_url || `/api/tts?text=${encodeURIComponent(card.context_sentence)}&lang=en`;
playAudio(url);
});
}
function flipFlashcard() {
@@ -363,7 +397,7 @@ export function initSrs() {
quizContextHint.classList.add('hidden');
}
quizAudioBtn.classList.add('hidden');
if (quizAudioBtn) quizAudioBtn.classList.remove('hidden');
const correctText = card.translation;
const all = getAllCards();
@@ -436,6 +470,16 @@ export function initSrs() {
quizNextBtn.focus();
}
if (quizAudioBtn) {
quizAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
playAudio(url);
});
}
quizNextBtn.addEventListener('click', () => {
srsCurrentIndex++;
renderSrsCurrentCard();
+23 -3
View File
@@ -168,14 +168,34 @@ export function updateHeaderStats(total = 0, today = 0, srsCount = 0) {
}
// -----------------------------------------------------------------------
// Audio Player Helper (Safe stub / audio stream)
// Audio Player Helper (Safe stub / audio stream / Piper TTS)
// -----------------------------------------------------------------------
export function playAudio(url) {
if (!url) return;
let activeAudio = null;
export function playAudio(url, speed = 1.0) {
if (!url) return null;
try {
if (activeAudio) {
try {
activeAudio.pause();
activeAudio.currentTime = 0;
} catch (e) {}
}
const audio = new Audio(url);
if (speed && speed !== 1.0) {
audio.playbackRate = speed;
}
activeAudio = audio;
audio.play().catch(e => console.warn('Audio playback not supported or blocked:', e));
return audio;
} catch (e) {
console.warn('Audio play error:', e);
return null;
}
}
export function playTextSpeech(text, lang = 'en', speed = 1.0) {
if (!text || !text.trim()) return null;
const url = `/api/tts?text=${encodeURIComponent(text.trim())}&lang=${lang}&speed=${speed}`;
return playAudio(url, speed);
}