feat(tts): integrate autonomous Piper neural TTS with zero-latency pre-warming for words and context
This commit is contained in:
1 parent
915d2784a2
commit
a6fb4349e9
12 files changed
+514
-27
No files matched your search
@@ -905,6 +905,37 @@ body.drawer-open {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.audio-xs-btn {
|
||||
background-color: var(--primary-dim);
|
||||
border: 1px solid var(--primary);
|
||||
color: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.audio-xs-btn:hover {
|
||||
background-color: var(--primary);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.popup-sentence-audio-btn {
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.popup-sentence-audio-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background-color: var(--primary-dim);
|
||||
}
|
||||
|
||||
.definition-box {
|
||||
background-color: var(--bg-main);
|
||||
border-left: 3px solid var(--primary);
|
||||
|
||||
+13
-4
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=5.5">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=6.4">
|
||||
|
||||
<!-- PWA meta and icons -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
@@ -319,7 +319,10 @@
|
||||
<p id="fc-back-def" class="detail-text"></p>
|
||||
</div>
|
||||
<div class="fc-back-detail-box" id="fc-back-context-box">
|
||||
<span class="detail-label">Контекст:</span>
|
||||
<div class="fc-detail-header-row" style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<span class="detail-label">Контекст:</span>
|
||||
<button type="button" id="fc-back-context-audio-btn" class="icon-btn audio-xs-btn" title="Озвучить предложение контекста">🔊</button>
|
||||
</div>
|
||||
<p id="fc-back-context" class="detail-text"></p>
|
||||
</div>
|
||||
<div class="fc-back-detail-box" id="fc-back-notes-box">
|
||||
@@ -1671,13 +1674,19 @@
|
||||
<button type="button" id="popup-translate-sentence-btn" class="btn btn-xs btn-outline popup-sentence-btn" title="Перевести всё предложение через локальный ИИ">
|
||||
🌐 Перевести предложение
|
||||
</button>
|
||||
<button type="button" id="popup-audio-sentence-btn" class="btn btn-xs btn-outline popup-sentence-audio-btn" title="Озвучить всё предложение нейросетью">
|
||||
🔊 Озвучить
|
||||
</button>
|
||||
<span id="popup-ai-source-badge" class="popup-ai-source-badge hidden">⚡ Local AI</span>
|
||||
</div>
|
||||
<div id="popup-sentence-loading" class="popup-sentence-loading hidden">
|
||||
<span class="spinner-sm"></span> Локальный ИИ переводит...
|
||||
</div>
|
||||
<div id="popup-sentence-translation-wrap" class="popup-sentence-translation-wrap hidden">
|
||||
<div class="popup-sentence-ru" id="popup-sentence-ru"></div>
|
||||
<div class="popup-sentence-ru-row" style="display: flex; align-items: flex-start; justify-content: space-between; gap: 8px;">
|
||||
<div class="popup-sentence-ru" id="popup-sentence-ru"></div>
|
||||
<button type="button" id="popup-audio-ru-btn" class="icon-btn audio-xs-btn" style="flex-shrink: 0;" title="Озвучить русский перевод">🔊 RU</button>
|
||||
</div>
|
||||
<div id="popup-context-meaning-wrap" class="popup-context-meaning-wrap hidden">
|
||||
<span class="context-meaning-label">🎯 В контексте:</span>
|
||||
<strong id="popup-context-meaning-val" class="context-meaning-val"></strong>
|
||||
@@ -1697,7 +1706,7 @@
|
||||
<!-- Audio Element for native streaming -->
|
||||
<audio id="global-audio-player" preload="none"></audio>
|
||||
|
||||
<script type="module" src="/static/js/app.js?v=5.5"></script>
|
||||
<script type="module" src="/static/js/app.js?v=6.4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
// Service Worker for Notes English PWA (Stage 2)
|
||||
// Strategy: Cache-first for static assets, Network-first for API calls.
|
||||
|
||||
const CACHE_NAME = 'notes-v6.3';
|
||||
const CACHE_NAME = 'notes-v6.4';
|
||||
const OFFLINE_URL = '/';
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
|
||||
Reference in new issue
Block a user