diff --git a/js/ai-teacher-app.js b/js/ai-teacher-app.js
index be86fef..a3aa71a 100644
--- a/js/ai-teacher-app.js
+++ b/js/ai-teacher-app.js
@@ -33,6 +33,14 @@ export class AITeacherApp {
this.currentSegmentIndex = 0;
this.isPlaying = false;
this.autoNavigate = true; // 默认启用自动翻页
+
+ // 音频上下文相关
+ this.audioContext = null;
+ this.audioSource = null;
+ this.analyzerNode = null;
+ this.audioContextInitialized = false;
+ this.lipSyncAnimationId = null;
+ this.frequencyData = null;
this.init();
}
@@ -345,13 +353,75 @@ export class AITeacherApp {
// 创建URL并设置到音频播放器
const audioUrl = URL.createObjectURL(blob);
- // self.Live2DController.playAudio(audioUrl)
this.audioPlayer.src = audioUrl;
this.audioPlayer.style.display = 'block';
// 高亮当前段中的句子
this.highlightCurrentSegmentSentences();
+ // 播放音频并启用口型同步
+ if (this.live2dController && this.live2dController.initialized) {
+ // 创建一个新的音频上下文
+ if (!this.audioContext) {
+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
+
+ // 创建媒体元素源
+ if (!this.audioSource) {
+ this.audioSource = this.audioContext.createMediaElementSource(this.audioPlayer);
+ }
+
+ // 启动音频分析
+ if (!this.analyzerNode) {
+ this.analyzerNode = this.audioContext.createAnalyser();
+ this.analyzerNode.fftSize = 256;
+ this.analyzerNode.smoothingTimeConstant = 0.3;
+ this.analyzerNode.minDecibels = -100;
+ this.analyzerNode.maxDecibels = -30;
+ }
+
+ // 连接音频源和分析器
+ this.audioSource.connect(this.analyzerNode);
+ this.analyzerNode.connect(this.audioContext.destination);
+
+ // 根据当前使用的模型选择合适的动作
+ const modelName = this.live2dController.model_setting.model;
+ let motionGroup = '';
+ let motionIndex = 0;
+
+ // 根据不同模型选择不同的动作组和索引
+ switch (modelName) {
+ case 'Haru':
+ motionGroup = 'haru_g_m';
+ motionIndex = Math.floor(Math.random() * 26) + 1; // 随机选择1-26之间的动作
+ break;
+ case 'Hiyori':
+ motionGroup = 'Hiyori_m';
+ motionIndex = Math.floor(Math.random() * 10) + 1; // 随机选择1-10之间的动作
+ break;
+ case 'Mao':
+ motionGroup = 'mtn_';
+ motionIndex = Math.floor(Math.random() * 4) + 1; // 随机选择1-4之间的动作
+ break;
+ case 'Mark':
+ motionGroup = 'mark_m';
+ motionIndex = Math.floor(Math.random() * 5) + 1; // 随机选择1-5之间的动作
+ break;
+ default:
+ // 如果没有匹配的模型,不播放动作
+ break;
+ }
+
+ // 如果有匹配的动作组,播放动作
+ if (motionGroup) {
+ try {
+ this.live2dController.playMotion(motionGroup, motionIndex);
+ } catch (error) {
+ console.log('播放动作时出错:', error);
+ }
+ }
+ }
+
// 播放音频
this.audioPlayer.play();
this.isPlaying = true;
@@ -364,10 +434,19 @@ export class AITeacherApp {
// 监听音频播放结束事件
this.audioPlayer.onended = () => {
+ // 结束口型同步
+ if (this.live2dController && this.live2dController.initialized) {
+ this.live2dController.setMouthOpenY(0);
+ }
+
// 播放下一段
this.currentSegmentIndex++;
this.playCurrentSegment();
};
+
+ // 启动口型同步动画
+ this.frequencyData = new Uint8Array(this.analyzerNode.frequencyBinCount);
+ this.lipSyncAnimationId = requestAnimationFrame(this.updateLipSync.bind(this));
} catch (error) {
console.error('播放音频时出错:', error);
this.showMessage('播放音频时出错: ' + error.message, true);
@@ -375,6 +454,244 @@ export class AITeacherApp {
}
}
+ pauseAudio() {
+ if (this.audioPlayer && !this.audioPlayer.paused) {
+ this.audioPlayer.pause();
+ this.isPlaying = false;
+
+ // 更新播放按钮状态
+ const playBtn = document.getElementById('play-btn');
+ playBtn.innerHTML = ' 播放';
+ playBtn.classList.remove('btn-warning');
+ playBtn.classList.add('btn-success');
+
+ // 暂停时重置口型
+ if (this.live2dController && this.live2dController.initialized) {
+ this.live2dController.setMouthOpenY(0);
+ }
+
+ // 暂停口型同步动画
+ if (this.lipSyncAnimationId) {
+ cancelAnimationFrame(this.lipSyncAnimationId);
+ this.lipSyncAnimationId = null;
+ }
+ } else if (this.audioPlayer && this.audioPlayer.paused) {
+ this.audioPlayer.play();
+ this.isPlaying = true;
+
+ // 更新播放按钮状态
+ const playBtn = document.getElementById('play-btn');
+ playBtn.innerHTML = ' 暂停';
+ playBtn.classList.remove('btn-success');
+ playBtn.classList.add('btn-warning');
+
+ // 启动口型同步动画
+ this.frequencyData = new Uint8Array(this.analyzerNode.frequencyBinCount);
+ this.lipSyncAnimationId = requestAnimationFrame(this.updateLipSync.bind(this));
+ }
+ }
+
+ resumeAudio() {
+ if (this.audioPlayer && this.audioPlayer.paused) {
+ this.audioPlayer.play();
+ this.isPlaying = true;
+
+ // 更新播放按钮状态
+ const playBtn = document.getElementById('play-btn');
+ playBtn.innerHTML = ' 暂停';
+ playBtn.classList.remove('btn-success');
+ playBtn.classList.add('btn-warning');
+
+ // 启动口型同步动画
+ this.frequencyData = new Uint8Array(this.analyzerNode.frequencyBinCount);
+ this.lipSyncAnimationId = requestAnimationFrame(this.updateLipSync.bind(this));
+ }
+ }
+
+ stopAudio() {
+ if (this.audioPlayer) {
+ this.audioPlayer.pause();
+ this.audioPlayer.currentTime = 0;
+ this.audioPlayer.style.display = 'none';
+ this.isPlaying = false;
+ this.currentSegmentIndex = 0;
+
+ // 更新播放按钮状态
+ const playBtn = document.getElementById('play-btn');
+ playBtn.innerHTML = ' 播放';
+ playBtn.classList.remove('btn-warning');
+ playBtn.classList.add('btn-success');
+
+ // 清除高亮
+ this.clearHighlights();
+
+ // 重置字幕
+ const subtitleText = document.getElementById('subtitle-text');
+ const subtitleContainer = document.getElementById('subtitle-container');
+
+ if (subtitleText) {
+ subtitleText.textContent = '字幕将在播放讲解时显示在这里';
+ subtitleContainer.classList.remove('active');
+ }
+
+ // 停止口型同步
+ if (this.live2dController && this.live2dController.initialized) {
+ this.live2dController.setMouthOpenY(0);
+
+ // 断开音频分析器连接
+ if (this.audioSource) {
+ try {
+ this.audioSource.disconnect();
+ } catch (error) {
+ console.log('断开音频源时出错:', error);
+ }
+ }
+
+ if (this.analyzerNode) {
+ try {
+ this.analyzerNode.disconnect();
+ } catch (error) {
+ console.log('断开分析器时出错:', error);
+ }
+ }
+
+ // 播放一个空闲动作
+ setTimeout(() => {
+ // 根据当前使用的模型选择合适的空闲动作
+ const modelName = this.live2dController.model_setting.model;
+
+ switch (modelName) {
+ case 'Haru':
+ this.live2dController.playMotion('haru_g_idle', 0);
+ break;
+ case 'Hiyori':
+ // Hiyori没有特定的idle动作,使用第一个动作
+ this.live2dController.playMotion('Hiyori_m', 1);
+ break;
+ case 'Mao':
+ // 对于Mao,使用sample_01作为空闲动作
+ this.live2dController.playMotion('sample_', 1);
+ break;
+ case 'Mark':
+ this.live2dController.playMotion('mark_m', 1);
+ break;
+ default:
+ // 如果没有匹配的模型,不播放动作
+ break;
+ }
+ }, 500);
+ }
+
+ // 停止口型同步动画
+ if (this.lipSyncAnimationId) {
+ cancelAnimationFrame(this.lipSyncAnimationId);
+ this.lipSyncAnimationId = null;
+ }
+ }
+ }
+
+ finishPlayback() {
+ // 播放完成后重置状态
+ this.isPlaying = false;
+ this.currentSegmentIndex = 0;
+
+ // 更新播放按钮状态
+ const playBtn = document.getElementById('play-btn');
+ playBtn.innerHTML = ' 播放';
+ playBtn.classList.remove('btn-warning');
+ playBtn.classList.add('btn-success');
+
+ // 清除高亮
+ this.clearHighlights();
+
+ // 重置字幕
+ const subtitleText = document.getElementById('subtitle-text');
+ const subtitleContainer = document.getElementById('subtitle-container');
+
+ if (subtitleText) {
+ subtitleText.textContent = '字幕将在播放讲解时显示在这里';
+ subtitleContainer.classList.remove('active');
+ }
+
+ // 停止口型同步
+ if (this.live2dController && this.live2dController.initialized) {
+ this.live2dController.setMouthOpenY(0);
+
+ // 断开音频分析器连接
+ if (this.audioSource) {
+ try {
+ this.audioSource.disconnect();
+ } catch (error) {
+ console.log('断开音频源时出错:', error);
+ }
+ }
+
+ if (this.analyzerNode) {
+ try {
+ this.analyzerNode.disconnect();
+ } catch (error) {
+ console.log('断开分析器时出错:', error);
+ }
+ }
+
+ // 播放一个空闲动作
+ setTimeout(() => {
+ // 根据当前使用的模型选择合适的空闲动作
+ const modelName = this.live2dController.model_setting.model;
+
+ switch (modelName) {
+ case 'Haru':
+ this.live2dController.playMotion('haru_g_idle', 0);
+ break;
+ case 'Hiyori':
+ // Hiyori没有特定的idle动作,使用第一个动作
+ this.live2dController.playMotion('Hiyori_m', 1);
+ break;
+ case 'Mao':
+ // 对于Mao,使用sample_01作为空闲动作
+ this.live2dController.playMotion('sample_', 1);
+ break;
+ case 'Mark':
+ this.live2dController.playMotion('mark_m', 1);
+ break;
+ default:
+ // 如果没有匹配的模型,不播放动作
+ break;
+ }
+ }, 500);
+ }
+
+ // 自动跳转到下一页(如果启用了自动翻页)
+ if (this.autoNavigate && this.pageNum < this.pdfDoc.numPages) {
+ // 延迟1.5秒后跳转,给用户一个短暂的停顿时间
+ setTimeout(() => {
+ this.onNextPage();
+ }, 1500);
+ }
+
+ // 停止口型同步动画
+ if (this.lipSyncAnimationId) {
+ cancelAnimationFrame(this.lipSyncAnimationId);
+ this.lipSyncAnimationId = null;
+ }
+ }
+
+ toggleAudio() {
+ if (!this.isPlaying) {
+ this.playAudio();
+ } else {
+ this.pauseAudio();
+ }
+ }
+
+ clearHighlights() {
+ // 清除所有句子的高亮
+ const sentences = document.querySelectorAll('.sentence');
+ sentences.forEach(sentence => {
+ sentence.classList.remove('highlight');
+ });
+ }
+
highlightCurrentSegmentSentences() {
// 清除所有高亮
this.clearHighlights();
@@ -426,106 +743,6 @@ export class AITeacherApp {
}
}
- pauseAudio() {
- if (this.audioPlayer && !this.audioPlayer.paused) {
- this.audioPlayer.pause();
- this.isPlaying = false;
-
- // 更新播放按钮状态
- const playBtn = document.getElementById('play-btn');
- playBtn.innerHTML = ' 播放';
- playBtn.classList.remove('btn-warning');
- playBtn.classList.add('btn-success');
- }
- }
-
- resumeAudio() {
- if (this.audioPlayer && this.audioPlayer.paused) {
- this.audioPlayer.play();
- this.isPlaying = true;
-
- // 更新播放按钮状态
- const playBtn = document.getElementById('play-btn');
- playBtn.innerHTML = ' 暂停';
- playBtn.classList.remove('btn-success');
- playBtn.classList.add('btn-warning');
- }
- }
-
- stopAudio() {
- if (this.audioPlayer) {
- this.audioPlayer.pause();
- this.audioPlayer.currentTime = 0;
- this.audioPlayer.style.display = 'none';
- this.isPlaying = false;
- this.currentSegmentIndex = 0;
-
- // 更新播放按钮状态
- const playBtn = document.getElementById('play-btn');
- playBtn.innerHTML = ' 播放';
- playBtn.classList.remove('btn-warning');
- playBtn.classList.add('btn-success');
-
- // 清除高亮
- this.clearHighlights();
-
- // 重置字幕
- const subtitleText = document.getElementById('subtitle-text');
- const subtitleContainer = document.getElementById('subtitle-container');
- if (subtitleText) {
- subtitleText.textContent = '字幕将在播放讲解时显示在这里';
- subtitleContainer.classList.remove('active');
- }
- }
- }
-
- finishPlayback() {
- // 播放完成后重置状态
- this.isPlaying = false;
- this.currentSegmentIndex = 0;
-
- // 更新播放按钮状态
- const playBtn = document.getElementById('play-btn');
- playBtn.innerHTML = ' 播放';
- playBtn.classList.remove('btn-warning');
- playBtn.classList.add('btn-success');
-
- // 清除高亮
- this.clearHighlights();
-
- // 重置字幕
- const subtitleText = document.getElementById('subtitle-text');
- const subtitleContainer = document.getElementById('subtitle-container');
- if (subtitleText) {
- subtitleText.textContent = '字幕将在播放讲解时显示在这里';
- subtitleContainer.classList.remove('active');
- }
-
- // 自动跳转到下一页(如果启用了自动翻页)
- if (this.autoNavigate && this.pageNum < this.pdfDoc.numPages) {
- // 延迟1.5秒后跳转,给用户一个短暂的停顿时间
- setTimeout(() => {
- this.onNextPage();
- }, 1500);
- }
- }
-
- toggleAudio() {
- if (!this.isPlaying) {
- this.playAudio();
- } else {
- this.pauseAudio();
- }
- }
-
- clearHighlights() {
- // 清除所有句子的高亮
- const sentences = document.querySelectorAll('.sentence');
- sentences.forEach(sentence => {
- sentence.classList.remove('highlight');
- });
- }
-
initVoiceControls() {
// 初始化音频播放器
this.audioPlayer = document.getElementById('audio-player');
@@ -613,4 +830,43 @@ export class AITeacherApp {
// 尝试加载可用的语音列表
this.loadVoices();
}
+
+ updateLipSync() {
+ if (!this.analyzerNode || !this.frequencyData || !this.isPlaying) {
+ return;
+ }
+
+ this.analyzerNode.getByteFrequencyData(this.frequencyData);
+
+ // 人声主要集中在85-255Hz范围内
+ // 我们的FFT大小是256,所以我们需要计算这个范围对应的索引
+ const minFreqIndex = Math.floor(85 / (this.audioContext.sampleRate / 2 / 128));
+ const maxFreqIndex = Math.floor(255 / (this.audioContext.sampleRate / 2 / 128));
+
+ // 计算语音频率范围内的平均值
+ let sum = 0;
+ let count = 0;
+
+ for (let i = minFreqIndex; i <= maxFreqIndex && i < this.frequencyData.length; i++) {
+ sum += this.frequencyData[i];
+ count++;
+ }
+
+ const average = count > 0 ? sum / count : 0;
+
+ // 应用平滑处理使口型动画更自然
+ const normalizedValue = Math.min(1, Math.max(0, average / 200));
+
+ // 添加一些随机变化使口型看起来更自然
+ const jitter = Math.random() * 0.05;
+ const finalValue = normalizedValue * (0.95 + jitter);
+
+ // 根据平均值更新口型
+ if (this.live2dController && this.live2dController.initialized) {
+ this.live2dController.setMouthOpenY(finalValue);
+ }
+
+ // 继续更新口型
+ this.lipSyncAnimationId = requestAnimationFrame(this.updateLipSync.bind(this));
+ }
}