Spaces:
Running
Running
Upload 27 files
Browse files- adapters/language-selector.js +153 -0
- adapters/voice-adapter.js +203 -47
- app.js +46 -6
- index.html +5 -0
- style.css +39 -0
adapters/language-selector.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================
|
| 2 |
+
// Language Selector — Ánh xạ ngôn ngữ tập trung
|
| 3 |
+
// Tái sử dụng cho Voice, LLM, và các adapter khác
|
| 4 |
+
// ============================================================
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Ánh xạ ngôn ngữ app → locale (BCP 47 format).
|
| 8 |
+
* Dùng cho Web Speech API, TTS, STT.
|
| 9 |
+
*/
|
| 10 |
+
var SPEECH_LOCALE_MAP = {
|
| 11 |
+
vi: 'vi-VN',
|
| 12 |
+
en: 'en-US',
|
| 13 |
+
ja: 'ja-JP'
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Ánh xạ ngôn ngữ app → Whisper language code (ISO 639-1).
|
| 18 |
+
* Dùng cho STT (Whisper ONNX).
|
| 19 |
+
*/
|
| 20 |
+
var WHISPER_LANG_MAP = {
|
| 21 |
+
vi: 'vietnamese',
|
| 22 |
+
en: 'english',
|
| 23 |
+
ja: 'japanese'
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Ánh xạ ngôn ngữ app → LLM language code.
|
| 28 |
+
* Dùng cho LLM adapter (Ollama, Hugging Face, v.v.).
|
| 29 |
+
*/
|
| 30 |
+
var LLM_LANG_MAP = {
|
| 31 |
+
vi: 'vi',
|
| 32 |
+
en: 'en',
|
| 33 |
+
ja: 'ja'
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* Danh sách ngôn ngữ được hỗ trợ.
|
| 38 |
+
*/
|
| 39 |
+
var SUPPORTED_LANGUAGES = ['vi', 'en', 'ja'];
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Lấy locale từ language code.
|
| 43 |
+
* @param {string} lang - Language code (vi, en, ja)
|
| 44 |
+
* @returns {string} Locale (vi-VN, en-US, ja-JP)
|
| 45 |
+
*/
|
| 46 |
+
function getLocale(lang) {
|
| 47 |
+
return SPEECH_LOCALE_MAP[lang] || lang;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Lấy Whisper language code từ language code.
|
| 52 |
+
* @param {string} lang - Language code (vi, en, ja)
|
| 53 |
+
* @returns {string} Whisper language code (vietnamese, english, japanese)
|
| 54 |
+
*/
|
| 55 |
+
function getWhisperLang(lang) {
|
| 56 |
+
return WHISPER_LANG_MAP[lang] || 'vietnamese';
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* Lấy LLM language code từ language code.
|
| 61 |
+
* @param {string} lang - Language code (vi, en, ja)
|
| 62 |
+
* @returns {string} LLM language code (vi, en, ja)
|
| 63 |
+
*/
|
| 64 |
+
function getLLMLang(lang) {
|
| 65 |
+
return LLM_LANG_MAP[lang] || 'vi';
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Kiểm tra ngôn ngữ có được hỗ trợ không.
|
| 70 |
+
* @param {string} lang - Language code
|
| 71 |
+
* @returns {boolean}
|
| 72 |
+
*/
|
| 73 |
+
function isLanguageSupported(lang) {
|
| 74 |
+
return SUPPORTED_LANGUAGES.includes(lang);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* Lấy ngôn ngữ mặc định.
|
| 79 |
+
* @returns {string} Language code (vi)
|
| 80 |
+
*/
|
| 81 |
+
function getDefaultLanguage() {
|
| 82 |
+
return 'vi';
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* Lấy danh sách ngôn ngữ được hỗ trợ.
|
| 87 |
+
* @returns {array} Danh sách language codes
|
| 88 |
+
*/
|
| 89 |
+
function getSupportedLanguages() {
|
| 90 |
+
return SUPPORTED_LANGUAGES.slice();
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* Lưu ngôn ngữ vào localStorage.
|
| 95 |
+
* @param {string} lang - Language code
|
| 96 |
+
*/
|
| 97 |
+
function saveLanguageToStorage(lang) {
|
| 98 |
+
if (typeof window !== 'undefined' && window.localStorage) {
|
| 99 |
+
try {
|
| 100 |
+
window.localStorage.setItem('selectedLanguage', lang);
|
| 101 |
+
console.log('[LanguageSelector] Saved language to localStorage:', lang);
|
| 102 |
+
} catch (err) {
|
| 103 |
+
console.error('[LanguageSelector] Error saving to localStorage:', err);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/**
|
| 109 |
+
* Lấy ngôn ngữ từ localStorage.
|
| 110 |
+
* @returns {string|null} Language code hoặc null nếu không có
|
| 111 |
+
*/
|
| 112 |
+
function getLanguageFromStorage() {
|
| 113 |
+
if (typeof window !== 'undefined' && window.localStorage) {
|
| 114 |
+
try {
|
| 115 |
+
var lang = window.localStorage.getItem('selectedLanguage');
|
| 116 |
+
return lang || null;
|
| 117 |
+
} catch (err) {
|
| 118 |
+
console.error('[LanguageSelector] Error reading from localStorage:', err);
|
| 119 |
+
return null;
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
return null;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/**
|
| 126 |
+
* Callback khi ngôn ngữ thay đổi.
|
| 127 |
+
* Lưu vào localStorage và có thể được override bởi các adapter để reload state.
|
| 128 |
+
* @param {string} lang - Language code mới
|
| 129 |
+
*/
|
| 130 |
+
function onLanguageChange(lang) {
|
| 131 |
+
console.log('[LanguageSelector] Language changed to:', lang);
|
| 132 |
+
saveLanguageToStorage(lang);
|
| 133 |
+
// Các adapter có thể override hàm này để reload state
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// ============================================================
|
| 137 |
+
// Export cho Node/test
|
| 138 |
+
// ============================================================
|
| 139 |
+
if (typeof module !== 'undefined' && module.exports) {
|
| 140 |
+
globalThis.SPEECH_LOCALE_MAP = SPEECH_LOCALE_MAP;
|
| 141 |
+
globalThis.WHISPER_LANG_MAP = WHISPER_LANG_MAP;
|
| 142 |
+
globalThis.LLM_LANG_MAP = LLM_LANG_MAP;
|
| 143 |
+
globalThis.SUPPORTED_LANGUAGES = SUPPORTED_LANGUAGES;
|
| 144 |
+
globalThis.getLocale = getLocale;
|
| 145 |
+
globalThis.getWhisperLang = getWhisperLang;
|
| 146 |
+
globalThis.getLLMLang = getLLMLang;
|
| 147 |
+
globalThis.isLanguageSupported = isLanguageSupported;
|
| 148 |
+
globalThis.getDefaultLanguage = getDefaultLanguage;
|
| 149 |
+
globalThis.getSupportedLanguages = getSupportedLanguages;
|
| 150 |
+
globalThis.saveLanguageToStorage = saveLanguageToStorage;
|
| 151 |
+
globalThis.getLanguageFromStorage = getLanguageFromStorage;
|
| 152 |
+
globalThis.onLanguageChange = onLanguageChange;
|
| 153 |
+
}
|
adapters/voice-adapter.js
CHANGED
|
@@ -1,23 +1,14 @@
|
|
| 1 |
// ============================================================
|
| 2 |
-
// Voice Adapter — STT (Whisper ONNX via Transformers.js) + TTS (
|
| 3 |
// STT: chạy offline trong browser, không cần server
|
| 4 |
-
// TTS: Web Speech API
|
| 5 |
// ============================================================
|
| 6 |
|
| 7 |
-
/
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
en: 'en-US',
|
| 13 |
-
ja: 'ja-JP'
|
| 14 |
-
};
|
| 15 |
-
|
| 16 |
-
// Whisper language codes (ISO 639-1)
|
| 17 |
-
var WHISPER_LANG_MAP = {
|
| 18 |
-
vi: 'vietnamese',
|
| 19 |
-
en: 'english',
|
| 20 |
-
ja: 'japanese'
|
| 21 |
};
|
| 22 |
|
| 23 |
// === Trạng thái nội bộ ===
|
|
@@ -28,6 +19,10 @@ var _mediaRecorder = null; // MediaRecorder instance
|
|
| 28 |
var _audioChunks = []; // Chunks audio thu được
|
| 29 |
var _whisperPipeline = null; // Transformers.js pipeline (lazy load)
|
| 30 |
var _whisperLoading = false; // Đang load model
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
// Whisper model — nhỏ, đa ngôn ngữ, chạy được trên browser
|
| 33 |
var WHISPER_MODEL_ID = 'onnx-community/whisper-base';
|
|
@@ -46,30 +41,128 @@ function initVoiceAdapter() {
|
|
| 46 |
);
|
| 47 |
_ttsSupported = !!(
|
| 48 |
typeof window !== 'undefined' &&
|
| 49 |
-
window.
|
| 50 |
);
|
| 51 |
return { sttSupported: _sttSupported, ttsSupported: _ttsSupported };
|
| 52 |
}
|
| 53 |
|
| 54 |
// ============================================================
|
| 55 |
-
// TTS — Text to Speech (Web Speech API
|
| 56 |
// ============================================================
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
function getVoicesForLang(lang) {
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
var
|
| 62 |
-
var
|
| 63 |
-
|
| 64 |
-
var vLang = (v.lang || '').toLowerCase();
|
| 65 |
-
return vLang === locale.toLowerCase() || vLang.startsWith(prefix + '-') || vLang.startsWith(prefix + '_');
|
| 66 |
-
});
|
| 67 |
-
filtered.sort(function (a, b) {
|
| 68 |
-
if (a.localService && !b.localService) return -1;
|
| 69 |
-
if (!a.localService && b.localService) return 1;
|
| 70 |
-
return 0;
|
| 71 |
-
});
|
| 72 |
-
return filtered;
|
| 73 |
}
|
| 74 |
|
| 75 |
function getDefaultVoice(lang) {
|
|
@@ -77,27 +170,91 @@ function getDefaultVoice(lang) {
|
|
| 77 |
return voices.length > 0 ? voices[0] : null;
|
| 78 |
}
|
| 79 |
|
| 80 |
-
function speakText(text, lang, voiceName) {
|
| 81 |
if (!_ttsSupported || !text || !text.trim()) return;
|
| 82 |
-
|
| 83 |
-
var
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
}
|
| 90 |
-
if (!selectedVoice) selectedVoice = getDefaultVoice(lang);
|
| 91 |
-
if (selectedVoice) utterance.voice = selectedVoice;
|
| 92 |
-
window.speechSynthesis.speak(utterance);
|
| 93 |
}
|
| 94 |
|
| 95 |
function stopSpeaking() {
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
|
| 99 |
function isSpeaking() {
|
| 100 |
-
return
|
| 101 |
}
|
| 102 |
|
| 103 |
// ============================================================
|
|
@@ -265,7 +422,7 @@ async function startVoiceInput(lang, onInterim, onFinal, onError) {
|
|
| 265 |
});
|
| 266 |
|
| 267 |
var audioData = await _decodeAudioBlob(audioBlob);
|
| 268 |
-
var whisperLang =
|
| 269 |
|
| 270 |
var result = await pipeline(audioData, {
|
| 271 |
language: whisperLang,
|
|
@@ -337,7 +494,6 @@ if (typeof window !== 'undefined') {
|
|
| 337 |
// Export cho Node/test (stub functions)
|
| 338 |
// ============================================================
|
| 339 |
if (typeof module !== 'undefined' && module.exports) {
|
| 340 |
-
globalThis.SPEECH_LOCALE_MAP = SPEECH_LOCALE_MAP;
|
| 341 |
globalThis.initVoiceAdapter = function () { return { sttSupported: false, ttsSupported: false }; };
|
| 342 |
globalThis.getVoicesForLang = function () { return []; };
|
| 343 |
globalThis.getDefaultVoice = function () { return null; };
|
|
|
|
| 1 |
// ============================================================
|
| 2 |
+
// Voice Adapter — STT (Whisper ONNX via Transformers.js) + TTS (MMS-TTS via Transformers.js)
|
| 3 |
// STT: chạy offline trong browser, không cần server
|
| 4 |
+
// TTS: MMS-TTS (Xenova) - hỗ trợ en, vi; fallback Web Speech API cho ja
|
| 5 |
// ============================================================
|
| 6 |
|
| 7 |
+
// TTS Model IDs (Xenova ONNX versions)
|
| 8 |
+
var TTS_MODELS = {
|
| 9 |
+
en: 'Xenova/mms-tts-eng',
|
| 10 |
+
vi: 'Xenova/mms-tts-vie',
|
| 11 |
+
ja: null // Use Web Speech API for Japanese
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
};
|
| 13 |
|
| 14 |
// === Trạng thái nội bộ ===
|
|
|
|
| 19 |
var _audioChunks = []; // Chunks audio thu được
|
| 20 |
var _whisperPipeline = null; // Transformers.js pipeline (lazy load)
|
| 21 |
var _whisperLoading = false; // Đang load model
|
| 22 |
+
var _ttsPipelines = {}; // Cache pipelines per language
|
| 23 |
+
var _ttsModelLoading = {}; // Track loading state per language
|
| 24 |
+
var _isSpeaking = false;
|
| 25 |
+
var _audioContext = null;
|
| 26 |
|
| 27 |
// Whisper model — nhỏ, đa ngôn ngữ, chạy được trên browser
|
| 28 |
var WHISPER_MODEL_ID = 'onnx-community/whisper-base';
|
|
|
|
| 41 |
);
|
| 42 |
_ttsSupported = !!(
|
| 43 |
typeof window !== 'undefined' &&
|
| 44 |
+
(typeof AudioContext !== 'undefined' || typeof window.webkitAudioContext !== 'undefined')
|
| 45 |
);
|
| 46 |
return { sttSupported: _sttSupported, ttsSupported: _ttsSupported };
|
| 47 |
}
|
| 48 |
|
| 49 |
// ============================================================
|
| 50 |
+
// TTS — Text to Speech (MMS-TTS via Transformers.js + Web Speech API fallback)
|
| 51 |
// ============================================================
|
| 52 |
|
| 53 |
+
/**
|
| 54 |
+
* Lazy-load TTS pipeline cho ngôn ngữ.
|
| 55 |
+
* @param {string} lang - Language code (en, vi, ja)
|
| 56 |
+
* @param {function} onStatus - Callback(message) để hiển thị trạng thái loading
|
| 57 |
+
* @returns {Promise<object>} Transformers.js pipeline hoặc null nếu fallback
|
| 58 |
+
*/
|
| 59 |
+
async function _getTTSPipeline(lang, onStatus) {
|
| 60 |
+
var langCode = getLLMLang(lang);
|
| 61 |
+
var modelId = TTS_MODELS[langCode];
|
| 62 |
+
|
| 63 |
+
// Fallback to Web Speech API cho tiếng Nhật
|
| 64 |
+
if (!modelId) {
|
| 65 |
+
console.log('[TTS] No model for', langCode, '- using Web Speech API fallback');
|
| 66 |
+
return null;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if (_ttsPipelines[langCode]) return _ttsPipelines[langCode];
|
| 70 |
+
if (_ttsModelLoading[langCode]) {
|
| 71 |
+
// Chờ cho đến khi load xong
|
| 72 |
+
return new Promise(function (resolve, reject) {
|
| 73 |
+
var interval = setInterval(function () {
|
| 74 |
+
if (_ttsPipelines[langCode]) {
|
| 75 |
+
clearInterval(interval);
|
| 76 |
+
resolve(_ttsPipelines[langCode]);
|
| 77 |
+
} else if (!_ttsModelLoading[langCode]) {
|
| 78 |
+
clearInterval(interval);
|
| 79 |
+
reject(new Error('TTS model load failed'));
|
| 80 |
+
}
|
| 81 |
+
}, 200);
|
| 82 |
+
});
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
_ttsModelLoading[langCode] = true;
|
| 86 |
+
if (onStatus) onStatus('⏳ Đang tải TTS model lần đầu (~100MB)...');
|
| 87 |
+
console.log('[TTS] Loading model:', modelId);
|
| 88 |
+
|
| 89 |
+
try {
|
| 90 |
+
var transformers = await import(
|
| 91 |
+
'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3/dist/transformers.min.js'
|
| 92 |
+
);
|
| 93 |
+
|
| 94 |
+
var pipeline = await transformers.pipeline(
|
| 95 |
+
'text-to-speech',
|
| 96 |
+
modelId,
|
| 97 |
+
{
|
| 98 |
+
dtype: 'fp32',
|
| 99 |
+
device: 'wasm',
|
| 100 |
+
}
|
| 101 |
+
);
|
| 102 |
+
|
| 103 |
+
_ttsPipelines[langCode] = pipeline;
|
| 104 |
+
_ttsModelLoading[langCode] = false;
|
| 105 |
+
if (onStatus) onStatus('✅ TTS model đã sẵn sàng');
|
| 106 |
+
console.log('[TTS] Model loaded successfully');
|
| 107 |
+
return pipeline;
|
| 108 |
+
} catch (err) {
|
| 109 |
+
_ttsModelLoading[langCode] = false;
|
| 110 |
+
console.error('[TTS] Load error:', err);
|
| 111 |
+
throw err;
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* Phát âm thanh từ audio data.
|
| 117 |
+
* @param {Float32Array} audioData - Audio samples
|
| 118 |
+
* @param {number} sampleRate - Sample rate (thường 22050 hoặc 24000)
|
| 119 |
+
*/
|
| 120 |
+
function _playAudio(audioData, sampleRate) {
|
| 121 |
+
if (!_audioContext) {
|
| 122 |
+
_audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
var audioBuffer = _audioContext.createBuffer(1, audioData.length, sampleRate);
|
| 126 |
+
var channelData = audioBuffer.getChannelData(0);
|
| 127 |
+
for (var i = 0; i < audioData.length; i++) {
|
| 128 |
+
channelData[i] = audioData[i];
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
var source = _audioContext.createBufferSource();
|
| 132 |
+
source.buffer = audioBuffer;
|
| 133 |
+
source.connect(_audioContext.destination);
|
| 134 |
+
source.onended = function () {
|
| 135 |
+
_isSpeaking = false;
|
| 136 |
+
};
|
| 137 |
+
source.start(0);
|
| 138 |
+
_isSpeaking = true;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/**
|
| 142 |
+
* Phát âm thanh bằng Web Speech API (fallback cho tiếng Nhật).
|
| 143 |
+
* @param {string} text - Text to speak
|
| 144 |
+
* @param {string} lang - Language code
|
| 145 |
+
*/
|
| 146 |
+
function _speakWithWebSpeechAPI(text, lang) {
|
| 147 |
+
if (!text || !text.trim()) return;
|
| 148 |
+
|
| 149 |
+
try {
|
| 150 |
+
window.speechSynthesis.cancel();
|
| 151 |
+
var utterance = new SpeechSynthesisUtterance(text);
|
| 152 |
+
utterance.lang = getLocale(lang);
|
| 153 |
+
window.speechSynthesis.speak(utterance);
|
| 154 |
+
_isSpeaking = true;
|
| 155 |
+
} catch (err) {
|
| 156 |
+
console.error('[TTS] Web Speech API error:', err);
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
function getVoicesForLang(lang) {
|
| 161 |
+
// MMS-TTS hỗ trợ: en, vi
|
| 162 |
+
// Trả về danh sách "voices" (thực tế chỉ là language codes)
|
| 163 |
+
var langCode = getLLMLang(lang);
|
| 164 |
+
var supported = ['en', 'vi'];
|
| 165 |
+
return supported.includes(langCode) ? [{ name: langCode, lang: lang }] : [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
function getDefaultVoice(lang) {
|
|
|
|
| 170 |
return voices.length > 0 ? voices[0] : null;
|
| 171 |
}
|
| 172 |
|
| 173 |
+
async function speakText(text, lang, voiceName) {
|
| 174 |
if (!_ttsSupported || !text || !text.trim()) return;
|
| 175 |
+
|
| 176 |
+
var langCode = getLLMLang(lang);
|
| 177 |
+
|
| 178 |
+
try {
|
| 179 |
+
// Show TTS status
|
| 180 |
+
if (typeof showTTSStatus === 'function') {
|
| 181 |
+
showTTSStatus('⏳ Đang tải model TTS...');
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
// Thử dùng TTS model
|
| 185 |
+
var pipeline = await _getTTSPipeline(lang, function (msg) {
|
| 186 |
+
console.log('[TTS]', msg);
|
| 187 |
+
if (typeof showTTSStatus === 'function') {
|
| 188 |
+
showTTSStatus(msg);
|
| 189 |
+
}
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
if (pipeline) {
|
| 193 |
+
// TTS - MMS-TTS không cần speaker embeddings
|
| 194 |
+
if (typeof showTTSStatus === 'function') {
|
| 195 |
+
showTTSStatus('⏳ Đang tạo âm thanh...');
|
| 196 |
+
}
|
| 197 |
+
console.log('[TTS] Generating speech for:', text.substring(0, 50));
|
| 198 |
+
|
| 199 |
+
var result = await pipeline(text);
|
| 200 |
+
|
| 201 |
+
// result.audio là Float32Array, result.sampling_rate là sample rate
|
| 202 |
+
if (result && result.audio) {
|
| 203 |
+
if (typeof showTTSStatus === 'function') {
|
| 204 |
+
showTTSStatus('⏳ Đang phát âm thanh...');
|
| 205 |
+
}
|
| 206 |
+
_playAudio(result.audio, result.sampling_rate || 22050);
|
| 207 |
+
|
| 208 |
+
// Hide status sau 1 giây
|
| 209 |
+
setTimeout(function () {
|
| 210 |
+
if (typeof hideTTSStatus === 'function') {
|
| 211 |
+
hideTTSStatus();
|
| 212 |
+
}
|
| 213 |
+
}, 1000);
|
| 214 |
+
}
|
| 215 |
+
} else {
|
| 216 |
+
// Fallback to Web Speech API
|
| 217 |
+
if (typeof showTTSStatus === 'function') {
|
| 218 |
+
showTTSStatus('⏳ Đang phát âm thanh...');
|
| 219 |
+
}
|
| 220 |
+
_speakWithWebSpeechAPI(text, lang);
|
| 221 |
+
|
| 222 |
+
// Hide status sau 1 giây
|
| 223 |
+
setTimeout(function () {
|
| 224 |
+
if (typeof hideTTSStatus === 'function') {
|
| 225 |
+
hideTTSStatus();
|
| 226 |
+
}
|
| 227 |
+
}, 1000);
|
| 228 |
+
}
|
| 229 |
+
} catch (err) {
|
| 230 |
+
console.error('[TTS] Error:', err);
|
| 231 |
+
if (typeof hideTTSStatus === 'function') {
|
| 232 |
+
hideTTSStatus();
|
| 233 |
+
}
|
| 234 |
+
// Fallback to Web Speech API on error
|
| 235 |
+
_speakWithWebSpeechAPI(text, lang);
|
| 236 |
}
|
|
|
|
|
|
|
|
|
|
| 237 |
}
|
| 238 |
|
| 239 |
function stopSpeaking() {
|
| 240 |
+
_isSpeaking = false;
|
| 241 |
+
|
| 242 |
+
// Stop Web Speech API
|
| 243 |
+
if (typeof window !== 'undefined' && window.speechSynthesis) {
|
| 244 |
+
window.speechSynthesis.cancel();
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// Stop Web Audio API
|
| 248 |
+
if (_audioContext) {
|
| 249 |
+
try {
|
| 250 |
+
_audioContext.close();
|
| 251 |
+
} catch (e) {}
|
| 252 |
+
_audioContext = null;
|
| 253 |
+
}
|
| 254 |
}
|
| 255 |
|
| 256 |
function isSpeaking() {
|
| 257 |
+
return _isSpeaking || (typeof window !== 'undefined' && window.speechSynthesis && window.speechSynthesis.speaking);
|
| 258 |
}
|
| 259 |
|
| 260 |
// ============================================================
|
|
|
|
| 422 |
});
|
| 423 |
|
| 424 |
var audioData = await _decodeAudioBlob(audioBlob);
|
| 425 |
+
var whisperLang = getWhisperLang(lang);
|
| 426 |
|
| 427 |
var result = await pipeline(audioData, {
|
| 428 |
language: whisperLang,
|
|
|
|
| 494 |
// Export cho Node/test (stub functions)
|
| 495 |
// ============================================================
|
| 496 |
if (typeof module !== 'undefined' && module.exports) {
|
|
|
|
| 497 |
globalThis.initVoiceAdapter = function () { return { sttSupported: false, ttsSupported: false }; };
|
| 498 |
globalThis.getVoicesForLang = function () { return []; };
|
| 499 |
globalThis.getDefaultVoice = function () { return null; };
|
app.js
CHANGED
|
@@ -732,6 +732,29 @@ function showError(message) {
|
|
| 732 |
appendMessage(message, 'bot');
|
| 733 |
}
|
| 734 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
/**
|
| 736 |
* Tính tỉ lệ khớp (confidence) dựa trên trigger đã match.
|
| 737 |
* - Trigger chính xác (không chứa wildcard) → 100
|
|
@@ -871,6 +894,12 @@ async function initBot(lang) {
|
|
| 871 |
*/
|
| 872 |
async function changeLanguage(lang) {
|
| 873 |
currentLang = lang;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
await initBot(lang);
|
| 875 |
|
| 876 |
// Xóa lịch sử hội thoại
|
|
@@ -1095,7 +1124,7 @@ async function sendMessage() {
|
|
| 1095 |
return;
|
| 1096 |
}
|
| 1097 |
|
| 1098 |
-
// Hiển thị tin nhắn người dùng (kèm ảnh nếu có)
|
| 1099 |
appendMessage(text || '', 'user', undefined, undefined, undefined, attachment ? attachment.dataURL : undefined);
|
| 1100 |
|
| 1101 |
// Lưu user message vào history (nếu có ảnh mà không có text, ghi chú [image])
|
|
@@ -1997,16 +2026,27 @@ async function initializeApp() {
|
|
| 1997 |
await loadPreprocessedData();
|
| 1998 |
}
|
| 1999 |
|
| 2000 |
-
//
|
| 2001 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2002 |
|
| 2003 |
// Hiển thị lời chào khởi tạo
|
| 2004 |
if (bot) {
|
| 2005 |
-
var greeting = await bot.reply(USERNAME, GREETING_TRIGGERS[
|
| 2006 |
_skipHistoryOnce = true;
|
| 2007 |
appendMessage(greeting, 'bot');
|
| 2008 |
-
updateRulesList(
|
| 2009 |
-
updateMacrosList(
|
| 2010 |
}
|
| 2011 |
|
| 2012 |
// Load 10 messages gần nhất từ IndexedDB
|
|
|
|
| 732 |
appendMessage(message, 'bot');
|
| 733 |
}
|
| 734 |
|
| 735 |
+
/**
|
| 736 |
+
* Hiển thị TTS processing status.
|
| 737 |
+
* @param {string} message - Status message
|
| 738 |
+
*/
|
| 739 |
+
function showTTSStatus(message) {
|
| 740 |
+
var ttsStatus = document.getElementById('tts-status');
|
| 741 |
+
var ttsStatusText = document.getElementById('tts-status-text');
|
| 742 |
+
if (ttsStatus && ttsStatusText) {
|
| 743 |
+
ttsStatusText.textContent = message || 'Đang xử lý âm thanh...';
|
| 744 |
+
ttsStatus.classList.remove('hidden');
|
| 745 |
+
}
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
/**
|
| 749 |
+
* Ẩn TTS processing status.
|
| 750 |
+
*/
|
| 751 |
+
function hideTTSStatus() {
|
| 752 |
+
var ttsStatus = document.getElementById('tts-status');
|
| 753 |
+
if (ttsStatus) {
|
| 754 |
+
ttsStatus.classList.add('hidden');
|
| 755 |
+
}
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
/**
|
| 759 |
* Tính tỉ lệ khớp (confidence) dựa trên trigger đã match.
|
| 760 |
* - Trigger chính xác (không chứa wildcard) → 100
|
|
|
|
| 894 |
*/
|
| 895 |
async function changeLanguage(lang) {
|
| 896 |
currentLang = lang;
|
| 897 |
+
|
| 898 |
+
// Notify language selector
|
| 899 |
+
if (typeof onLanguageChange === 'function') {
|
| 900 |
+
onLanguageChange(lang);
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
await initBot(lang);
|
| 904 |
|
| 905 |
// Xóa lịch sử hội thoại
|
|
|
|
| 1124 |
return;
|
| 1125 |
}
|
| 1126 |
|
| 1127 |
+
// Hiển thị tin nhắn người dùng NGAY LẬP TỨC (kèm ảnh nếu có)
|
| 1128 |
appendMessage(text || '', 'user', undefined, undefined, undefined, attachment ? attachment.dataURL : undefined);
|
| 1129 |
|
| 1130 |
// Lưu user message vào history (nếu có ảnh mà không có text, ghi chú [image])
|
|
|
|
| 2026 |
await loadPreprocessedData();
|
| 2027 |
}
|
| 2028 |
|
| 2029 |
+
// Lấy ngôn ngữ từ localStorage, nếu không có thì dùng mặc định
|
| 2030 |
+
var savedLang = (typeof getLanguageFromStorage === 'function') ? getLanguageFromStorage() : null;
|
| 2031 |
+
var initialLang = (savedLang && (typeof isLanguageSupported === 'function' ? isLanguageSupported(savedLang) : true)) ? savedLang : 'vi';
|
| 2032 |
+
currentLang = initialLang;
|
| 2033 |
+
|
| 2034 |
+
// Cập nhật language selector UI
|
| 2035 |
+
var langSelector = document.getElementById('language-selector');
|
| 2036 |
+
if (langSelector) {
|
| 2037 |
+
langSelector.value = initialLang;
|
| 2038 |
+
}
|
| 2039 |
+
|
| 2040 |
+
// Khởi tạo bot với ngôn ngữ đã lưu
|
| 2041 |
+
await initBot(initialLang);
|
| 2042 |
|
| 2043 |
// Hiển thị lời chào khởi tạo
|
| 2044 |
if (bot) {
|
| 2045 |
+
var greeting = await bot.reply(USERNAME, GREETING_TRIGGERS[initialLang]);
|
| 2046 |
_skipHistoryOnce = true;
|
| 2047 |
appendMessage(greeting, 'bot');
|
| 2048 |
+
updateRulesList(initialLang);
|
| 2049 |
+
updateMacrosList(initialLang);
|
| 2050 |
}
|
| 2051 |
|
| 2052 |
// Load 10 messages gần nhất từ IndexedDB
|
index.html
CHANGED
|
@@ -121,6 +121,10 @@
|
|
| 121 |
</div>
|
| 122 |
</div>
|
| 123 |
<div id="message-display" class="message-display"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
<div class="input-area">
|
| 125 |
<button id="attach-button" title="Đính kèm ảnh" aria-label="Đính kèm ảnh">📎</button>
|
| 126 |
<input type="file" id="file-input" accept="image/*" class="hidden">
|
|
@@ -170,6 +174,7 @@
|
|
| 170 |
<script src="brain.js"></script>
|
| 171 |
<script src="data-loader.js"></script>
|
| 172 |
<script src="data/chat-history-db.js"></script>
|
|
|
|
| 173 |
<script src="adapters/text-similarity.js"></script>
|
| 174 |
<script src="adapters/specific-response.js"></script>
|
| 175 |
<script src="adapters/time-adapter.js"></script>
|
|
|
|
| 121 |
</div>
|
| 122 |
</div>
|
| 123 |
<div id="message-display" class="message-display"></div>
|
| 124 |
+
<div id="tts-status" class="tts-status hidden" aria-live="polite">
|
| 125 |
+
<span class="tts-spinner">⏳</span>
|
| 126 |
+
<span id="tts-status-text">Đang xử lý âm thanh...</span>
|
| 127 |
+
</div>
|
| 128 |
<div class="input-area">
|
| 129 |
<button id="attach-button" title="Đính kèm ảnh" aria-label="Đính kèm ảnh">📎</button>
|
| 130 |
<input type="file" id="file-input" accept="image/*" class="hidden">
|
|
|
|
| 174 |
<script src="brain.js"></script>
|
| 175 |
<script src="data-loader.js"></script>
|
| 176 |
<script src="data/chat-history-db.js"></script>
|
| 177 |
+
<script src="adapters/language-selector.js"></script>
|
| 178 |
<script src="adapters/text-similarity.js"></script>
|
| 179 |
<script src="adapters/specific-response.js"></script>
|
| 180 |
<script src="adapters/time-adapter.js"></script>
|
style.css
CHANGED
|
@@ -1081,3 +1081,42 @@ body {
|
|
| 1081 |
object-fit: cover;
|
| 1082 |
border: 1px solid #dee2e6;
|
| 1083 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1081 |
object-fit: cover;
|
| 1082 |
border: 1px solid #dee2e6;
|
| 1083 |
}
|
| 1084 |
+
|
| 1085 |
+
|
| 1086 |
+
/* === TTS Status Indicator === */
|
| 1087 |
+
.tts-status {
|
| 1088 |
+
display: flex;
|
| 1089 |
+
align-items: center;
|
| 1090 |
+
gap: 8px;
|
| 1091 |
+
padding: 8px 12px;
|
| 1092 |
+
background-color: #fff3cd;
|
| 1093 |
+
border-bottom: 1px solid #ffc107;
|
| 1094 |
+
color: #856404;
|
| 1095 |
+
font-size: 0.875rem;
|
| 1096 |
+
animation: slideDown 0.3s ease-out;
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
.tts-status.hidden {
|
| 1100 |
+
display: none;
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
.tts-spinner {
|
| 1104 |
+
display: inline-block;
|
| 1105 |
+
animation: spin 1s linear infinite;
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
@keyframes spin {
|
| 1109 |
+
0% { transform: rotate(0deg); }
|
| 1110 |
+
100% { transform: rotate(360deg); }
|
| 1111 |
+
}
|
| 1112 |
+
|
| 1113 |
+
@keyframes slideDown {
|
| 1114 |
+
from {
|
| 1115 |
+
opacity: 0;
|
| 1116 |
+
transform: translateY(-10px);
|
| 1117 |
+
}
|
| 1118 |
+
to {
|
| 1119 |
+
opacity: 1;
|
| 1120 |
+
transform: translateY(0);
|
| 1121 |
+
}
|
| 1122 |
+
}
|