chatbot-js / adapters /voice-adapter.js
mr4's picture
Upload 27 files
4b05d12 verified
Raw
History Blame Contribute Delete
17.9 kB
// ============================================================
// Voice Adapter — STT (Whisper ONNX via Transformers.js) + TTS (MMS-TTS via Transformers.js)
// STT: chạy offline trong browser, không cần server
// TTS: MMS-TTS (Xenova) - hỗ trợ en, vi; fallback Web Speech API cho ja
// ============================================================
// TTS Model IDs (Xenova ONNX versions)
var TTS_MODELS = {
en: 'Xenova/mms-tts-eng',
vi: 'Xenova/mms-tts-vie',
ja: null // Use Web Speech API for Japanese
};
// === Trạng thái nội bộ ===
var _isListening = false; // Đang thu âm
var _sttSupported = false; // Browser hỗ trợ STT (MediaRecorder + Transformers.js)
var _ttsSupported = false; // Browser hỗ trợ TTS
var _mediaRecorder = null; // MediaRecorder instance
var _audioChunks = []; // Chunks audio thu được
var _whisperPipeline = null; // Transformers.js pipeline (lazy load)
var _whisperLoading = false; // Đang load model
var _ttsPipelines = {}; // Cache pipelines per language
var _ttsModelLoading = {}; // Track loading state per language
var _isSpeaking = false;
var _audioContext = null;
// Whisper model — nhỏ, đa ngôn ngữ, chạy được trên browser
var WHISPER_MODEL_ID = 'onnx-community/whisper-base';
/**
* Khởi tạo Voice Adapter — kiểm tra browser support.
* STT dùng Whisper (cần MediaRecorder + WebAssembly/WebGPU).
* @returns {{ sttSupported: boolean, ttsSupported: boolean }}
*/
function initVoiceAdapter() {
_sttSupported = !!(
typeof window !== 'undefined' &&
typeof navigator !== 'undefined' &&
navigator.mediaDevices &&
typeof MediaRecorder !== 'undefined'
);
_ttsSupported = !!(
typeof window !== 'undefined' &&
(typeof AudioContext !== 'undefined' || typeof window.webkitAudioContext !== 'undefined')
);
return { sttSupported: _sttSupported, ttsSupported: _ttsSupported };
}
// ============================================================
// TTS — Text to Speech (MMS-TTS via Transformers.js + Web Speech API fallback)
// ============================================================
/**
* Lazy-load TTS pipeline cho ngôn ngữ.
* @param {string} lang - Language code (en, vi, ja)
* @param {function} onStatus - Callback(message) để hiển thị trạng thái loading
* @returns {Promise<object>} Transformers.js pipeline hoặc null nếu fallback
*/
async function _getTTSPipeline(lang, onStatus) {
var langCode = getLLMLang(lang);
var modelId = TTS_MODELS[langCode];
// Fallback to Web Speech API cho tiếng Nhật
if (!modelId) {
console.log('[TTS] No model for', langCode, '- using Web Speech API fallback');
return null;
}
if (_ttsPipelines[langCode]) return _ttsPipelines[langCode];
if (_ttsModelLoading[langCode]) {
// Chờ cho đến khi load xong
return new Promise(function (resolve, reject) {
var interval = setInterval(function () {
if (_ttsPipelines[langCode]) {
clearInterval(interval);
resolve(_ttsPipelines[langCode]);
} else if (!_ttsModelLoading[langCode]) {
clearInterval(interval);
reject(new Error('TTS model load failed'));
}
}, 200);
});
}
_ttsModelLoading[langCode] = true;
if (onStatus) onStatus('⏳ Đang tải TTS model lần đầu (~100MB)...');
console.log('[TTS] Loading model:', modelId);
try {
var transformers = await import(
'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3/dist/transformers.min.js'
);
var pipeline = await transformers.pipeline(
'text-to-speech',
modelId,
{
dtype: 'fp32',
device: 'wasm',
}
);
_ttsPipelines[langCode] = pipeline;
_ttsModelLoading[langCode] = false;
if (onStatus) onStatus('✅ TTS model đã sẵn sàng');
console.log('[TTS] Model loaded successfully');
return pipeline;
} catch (err) {
_ttsModelLoading[langCode] = false;
console.error('[TTS] Load error:', err);
throw err;
}
}
/**
* Phát âm thanh từ audio data.
* @param {Float32Array} audioData - Audio samples
* @param {number} sampleRate - Sample rate (thường 22050 hoặc 24000)
*/
function _playAudio(audioData, sampleRate) {
if (!_audioContext) {
_audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
var audioBuffer = _audioContext.createBuffer(1, audioData.length, sampleRate);
var channelData = audioBuffer.getChannelData(0);
for (var i = 0; i < audioData.length; i++) {
channelData[i] = audioData[i];
}
var source = _audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(_audioContext.destination);
source.onended = function () {
_isSpeaking = false;
};
source.start(0);
_isSpeaking = true;
}
/**
* Phát âm thanh bằng Web Speech API (fallback cho tiếng Nhật).
* @param {string} text - Text to speak
* @param {string} lang - Language code
*/
function _speakWithWebSpeechAPI(text, lang) {
if (!text || !text.trim()) return;
try {
window.speechSynthesis.cancel();
var utterance = new SpeechSynthesisUtterance(text);
utterance.lang = getLocale(lang);
window.speechSynthesis.speak(utterance);
_isSpeaking = true;
} catch (err) {
console.error('[TTS] Web Speech API error:', err);
}
}
function getVoicesForLang(lang) {
// MMS-TTS hỗ trợ: en, vi
// Trả về danh sách "voices" (thực tế chỉ là language codes)
var langCode = getLLMLang(lang);
var supported = ['en', 'vi'];
return supported.includes(langCode) ? [{ name: langCode, lang: lang }] : [];
}
function getDefaultVoice(lang) {
var voices = getVoicesForLang(lang);
return voices.length > 0 ? voices[0] : null;
}
async function speakText(text, lang, voiceName) {
if (!_ttsSupported || !text || !text.trim()) return;
var langCode = getLLMLang(lang);
try {
// Show TTS status
if (typeof showTTSStatus === 'function') {
showTTSStatus('⏳ Đang tải model TTS...');
}
// Thử dùng TTS model
var pipeline = await _getTTSPipeline(lang, function (msg) {
console.log('[TTS]', msg);
if (typeof showTTSStatus === 'function') {
showTTSStatus(msg);
}
});
if (pipeline) {
// TTS - MMS-TTS không cần speaker embeddings
if (typeof showTTSStatus === 'function') {
showTTSStatus('⏳ Đang tạo âm thanh...');
}
console.log('[TTS] Generating speech for:', text.substring(0, 50));
var result = await pipeline(text);
// result.audio là Float32Array, result.sampling_rate là sample rate
if (result && result.audio) {
if (typeof showTTSStatus === 'function') {
showTTSStatus('⏳ Đang phát âm thanh...');
}
_playAudio(result.audio, result.sampling_rate || 22050);
// Hide status sau 1 giây
setTimeout(function () {
if (typeof hideTTSStatus === 'function') {
hideTTSStatus();
}
}, 1000);
}
} else {
// Fallback to Web Speech API
if (typeof showTTSStatus === 'function') {
showTTSStatus('⏳ Đang phát âm thanh...');
}
_speakWithWebSpeechAPI(text, lang);
// Hide status sau 1 giây
setTimeout(function () {
if (typeof hideTTSStatus === 'function') {
hideTTSStatus();
}
}, 1000);
}
} catch (err) {
console.error('[TTS] Error:', err);
if (typeof hideTTSStatus === 'function') {
hideTTSStatus();
}
// Fallback to Web Speech API on error
_speakWithWebSpeechAPI(text, lang);
}
}
function stopSpeaking() {
_isSpeaking = false;
// Stop Web Speech API
if (typeof window !== 'undefined' && window.speechSynthesis) {
window.speechSynthesis.cancel();
}
// Stop Web Audio API
if (_audioContext) {
try {
_audioContext.close();
} catch (e) {}
_audioContext = null;
}
}
function isSpeaking() {
return _isSpeaking || (typeof window !== 'undefined' && window.speechSynthesis && window.speechSynthesis.speaking);
}
// ============================================================
// STT — Whisper ONNX via Transformers.js
// ============================================================
/**
* Lazy-load Whisper pipeline lần đầu tiên.
* Dùng dynamic import() từ CDN — giống llm-adapter.js, không cần script tag trong index.html.
* @param {function} onStatus - Callback(message) để hiển thị trạng thái loading
* @returns {Promise<object>} Transformers.js pipeline
*/
async function _getWhisperPipeline(onStatus) {
if (_whisperPipeline) return _whisperPipeline;
if (_whisperLoading) {
// Chờ cho đến khi load xong
return new Promise(function (resolve, reject) {
var interval = setInterval(function () {
if (_whisperPipeline) {
clearInterval(interval);
resolve(_whisperPipeline);
} else if (!_whisperLoading) {
clearInterval(interval);
reject(new Error('Whisper load failed'));
}
}, 200);
});
}
_whisperLoading = true;
if (onStatus) onStatus('⏳ Đang tải Whisper model lần đầu (~150MB)...');
console.log('[Whisper] Loading model:', WHISPER_MODEL_ID);
try {
// Dynamic import từ CDN — giống llm-adapter.js, không cần script tag trong index.html
var transformers = await import(
'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3/dist/transformers.min.js'
);
var pipeline = await transformers.pipeline(
'automatic-speech-recognition',
WHISPER_MODEL_ID,
{
dtype: { encoder_model: 'fp32', decoder_model_merged: 'q4' },
device: 'webgpu',
}
);
_whisperPipeline = pipeline;
_whisperLoading = false;
if (onStatus) onStatus('✅ Whisper model đã sẵn sàng');
console.log('[Whisper] Model loaded successfully');
return _whisperPipeline;
} catch (err) {
_whisperLoading = false;
console.error('[Whisper] Load error:', err);
throw err;
}
}
/**
* Chuyển AudioBuffer → Float32Array (mono, 16kHz) để Whisper xử lý.
* @param {AudioBuffer} audioBuffer
* @returns {Float32Array}
*/
function _audioBufferToFloat32(audioBuffer) {
// Whisper cần 16kHz mono
var targetSampleRate = 16000;
var sourceSampleRate = audioBuffer.sampleRate;
var sourceData = audioBuffer.getChannelData(0); // mono
if (sourceSampleRate === targetSampleRate) {
return sourceData;
}
// Resample đơn giản bằng linear interpolation
var ratio = sourceSampleRate / targetSampleRate;
var outputLength = Math.round(sourceData.length / ratio);
var output = new Float32Array(outputLength);
for (var i = 0; i < outputLength; i++) {
var srcIdx = i * ratio;
var srcIdxFloor = Math.floor(srcIdx);
var srcIdxCeil = Math.min(srcIdxFloor + 1, sourceData.length - 1);
var frac = srcIdx - srcIdxFloor;
output[i] = sourceData[srcIdxFloor] * (1 - frac) + sourceData[srcIdxCeil] * frac;
}
return output;
}
/**
* Decode audio blob thành Float32Array 16kHz mono.
* @param {Blob} audioBlob
* @returns {Promise<Float32Array>}
*/
async function _decodeAudioBlob(audioBlob) {
var arrayBuffer = await audioBlob.arrayBuffer();
var audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
try {
var audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
return _audioBufferToFloat32(audioBuffer);
} finally {
audioCtx.close();
}
}
/**
* Bắt đầu thu âm bằng MediaRecorder, sau đó transcribe bằng Whisper.
* @param {string} lang
* @param {function} onInterim - Callback(text) khi đang thu âm (hiển thị indicator)
* @param {function} onFinal - Callback(text) khi transcribe xong
* @param {function} [onError] - Callback(errorMessage) khi lỗi
*/
async function startVoiceInput(lang, onInterim, onFinal, onError) {
if (!_sttSupported) {
if (onError) onError('not-supported');
return;
}
if (_isListening) stopVoiceInput();
try {
var stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
_audioChunks = [];
_isListening = true;
// Hiển thị indicator đang thu âm
if (onInterim) onInterim('🎤 Đang nghe...');
// Chọn mime type được hỗ trợ
var mimeType = 'audio/webm';
if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = 'audio/ogg;codecs=opus';
}
if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = ''; // để browser tự chọn
}
var options = mimeType ? { mimeType: mimeType } : {};
_mediaRecorder = new MediaRecorder(stream, options);
_mediaRecorder.ondataavailable = function (e) {
if (e.data && e.data.size > 0) {
_audioChunks.push(e.data);
}
};
_mediaRecorder.onstop = async function () {
// Dừng tất cả tracks
stream.getTracks().forEach(function (t) { t.stop(); });
_isListening = false;
if (_audioChunks.length === 0) {
if (onError) onError('no-speech');
return;
}
var audioBlob = new Blob(_audioChunks, { type: mimeType || 'audio/webm' });
_audioChunks = [];
// Hiển thị trạng thái đang xử lý
if (onInterim) onInterim('⏳ Đang nhận dạng...');
try {
var pipeline = await _getWhisperPipeline(function (msg) {
if (onInterim) onInterim(msg);
});
var audioData = await _decodeAudioBlob(audioBlob);
var whisperLang = getWhisperLang(lang);
var result = await pipeline(audioData, {
language: whisperLang,
task: 'transcribe',
chunk_length_s: 30,
return_timestamps: false,
});
var text = (result && result.text) ? result.text.trim() : '';
console.log('[Whisper] Transcription:', text);
if (text) {
if (onFinal) onFinal(text);
} else {
if (onError) onError('no-speech');
}
} catch (transcribeErr) {
console.error('[Whisper] Transcription error:', transcribeErr);
if (onError) onError('transcribe-failed: ' + (transcribeErr.message || transcribeErr));
}
};
// Thu âm, tự dừng sau 10 giây
_mediaRecorder.start();
setTimeout(function () {
if (_mediaRecorder && _mediaRecorder.state === 'recording') {
_mediaRecorder.stop();
}
}, 10000);
} catch (err) {
_isListening = false;
console.error('[STT] Error:', err);
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
if (onError) onError('not-allowed');
} else {
if (onError) onError(err.message || 'unknown');
}
}
}
/**
* Dừng thu âm sớm (user bấm nút lần 2).
*/
function stopVoiceInput() {
if (_mediaRecorder && _mediaRecorder.state === 'recording') {
_mediaRecorder.stop(); // sẽ trigger onstop → transcribe
}
_isListening = false;
}
/**
* Kiểm tra đang thu âm.
* @returns {boolean}
*/
function isVoiceInputActive() {
return _isListening;
}
// ============================================================
// Alias private — dùng bởi app.js để tránh đệ quy với wrapper cùng tên
// ============================================================
if (typeof window !== 'undefined') {
window._voiceAdapterStartVoiceInput = startVoiceInput;
window._voiceAdapterStopVoiceInput = stopVoiceInput;
}
// ============================================================
// Export cho Node/test (stub functions)
// ============================================================
if (typeof module !== 'undefined' && module.exports) {
globalThis.initVoiceAdapter = function () { return { sttSupported: false, ttsSupported: false }; };
globalThis.getVoicesForLang = function () { return []; };
globalThis.getDefaultVoice = function () { return null; };
globalThis.speakText = function () {};
globalThis.stopSpeaking = function () {};
globalThis.isSpeaking = function () { return false; };
globalThis.startVoiceInput = function (lang, onInterim, onFinal, onError) {
if (onError) onError('not-supported');
};
globalThis.stopVoiceInput = function () {};
globalThis.isVoiceInputActive = function () { return false; };
}