Node.js与Electron应用中音频播放与UI同步问题求助
英语词汇学习应用音画同步问题修复
问题背景
基于Node.js和Electron开发的英语词汇学习应用,核心功能逻辑如下:
- 从API拉取10个单词,按用户自定义间隔自动逐个展示
- 每个单词先调用AWS Polly生成发音并播放,等待3秒后播放对应英文例句,完成后等待设定间隔切换至下一个单词
- 添加「重复当前单词及例句」按钮后出现音画不同步问题:点击按钮时,下一个单词可能提前展示但音频仍在播放上一个内容,或音频切换但UI未更新,破坏正常播放流程
问题代码文件
index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Words in English</title> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap" rel="stylesheet"> </head> <body> <div class="container"> <div id="word"></div> <div id="error"></div> <div class="controls"> <input type="number" id="intervalInput" min="1" max="999" value="1" placeholder="Intervalo"> <select id="voiceSelect"> <option value="Matthew">Matthew</option> <option value="Joanna">Joanna</option> </select> <button id="repeatButton">repeat</button> </div> </div> <script src="renderer.js"></script> </body> </html>
renderer.js
const {speak,updateVoiceConfig,pauseAudio,stopAudio,resumeAudio } = require('./polly/pollyServices.js'); let currentIndex = 0; let intervalId; let data; let time =5; async function fetchData() { try { const response = await fetch('http://localhost:3005/randomwords', { method: 'GET', headers: { 'Content-Type': 'application/json' } }); data = await response.json(); console.log(data); return data; } catch (error) { console.error('Error fetching data:', error); return null; } } function checkFetchApiStatus() { if (!data) { document.getElementById('error').innerText = 'No se pudieron obtener los datos de la API.'; return; } } async function loadDataIfNeeded() { if (currentIndex === 0 || !data || data.length === 0) { data = await fetchData(); } } function displayWord() { const word = data[currentIndex]; const wordElement = document.getElementById('word'); let content = ""; for (const key in word) { if (Object.hasOwnProperty.call(word, key) && key !== "wordID") { content += `${word[key]}\n`; } } wordElement.innerText = content.trim(); } let isPlaying = false; async function playWord(text) { if (isPlaying) { stopAudio(); } isPlaying = true; await speak(text); isPlaying = false; } async function nextWord() { await loadDataIfNeeded(); checkFetchApiStatus(); await displayWord(); await playWord(data[currentIndex].english); await new Promise(resolve => setTimeout(resolve, 3000)); await playWord(data[currentIndex].example_sentence); currentIndex = (currentIndex + 1) % data.length; intervalId = setTimeout(nextWord, time * 1000); } function setupListeners() { const intervalInput = document.getElementById('intervalInput'); const voiceSelect = document.getElementById('voiceSelect'); intervalInput.addEventListener('input', () => { time = intervalInput.value; }); voiceSelect.addEventListener('change', () => { const selectedVoice = voiceSelect.value; updateVoiceConfig(selectedVoice); }); const listenButton = document.getElementById('repeatButton'); listenButton.addEventListener('click', async function () { }); } window.onload = function() { setupListeners(); nextWord(); };
pollyServices.js
const AWS = require('./pollyConfig.js'); const { Howl } = require('howler'); const polly = new AWS.Polly(); let voiceId = 'Matthew'; // Voz predeterminada function updateVoiceConfig(newVoiceId) { voiceId = newVoiceId; } // Variable global para manejar la instancia actual del sonido let sound; async function speak(text) { try { const params = { Text: text, OutputFormat: 'mp3', SampleRate: '24000', VoiceId: voiceId, TextType: 'ssml', }; const ssml = `<speak><prosody rate="slow">${text}</prosody><break time="2s"/></speak>`; params.Text = ssml; const resultado = await polly.synthesizeSpeech(params).promise(); if (resultado.AudioStream instanceof Buffer) { // Detiene la reproducción actual si hay una if (sound) { sound.stop(); } // Crea una nueva instancia de Howl para el nuevo audio sound = new Howl({ src: ['data:audio/mpeg;base64,' + resultado.AudioStream.toString('base64')], format: ['mp3'], onplay: () => { console.log('Audio is playing'); }, onend: () => { console.log('Audio has finished playing'); } }); sound.play(); } else { console.error('Error al sintetizar el habla.'); } } catch (error) { console.error('Error al convertir texto a voz:', error); } } function pauseAudio() { if (sound && sound.playing()) { sound.pause(); } } function stopAudio() { if (sound) { sound.stop(); } } function resumeAudio() { if (sound && !sound.playing()) { sound.play(); } } module.exports = {speak,updateVoiceConfig,pauseAudio,stopAudio,resumeAudio} ;
问题根源
speak函数未返回音频播放完成的Promise,导致await speak()无法等待音频结束,流程提前推进- 自动切换的定时器
intervalId未在重复操作时清除,原有自动流程与重复操作并行执行 - 重复按钮逻辑未处理当前正在播放的音频和流程状态,导致异步操作冲突
修复方案
1. 修复pollyServices.js的speak函数,实现音频播放等待
修改speak函数返回Promise,在音频播放结束时resolve,确保await能正确等待:
async function speak(text) { return new Promise((resolve, reject) => { try { const params = { Text: text, OutputFormat: 'mp3', SampleRate: '24000', VoiceId: voiceId, TextType: 'ssml', }; const ssml = `<speak><prosody rate="slow">${text}</prosody><break time="2s"/></speak>`; params.Text = ssml; polly.synthesizeSpeech(params).promise().then(resultado => { if (resultado.AudioStream instanceof Buffer) { if (sound) { sound.stop(); } sound = new Howl({ src: ['data:audio/mpeg;base64,' + resultado.AudioStream.toString('base64')], format: ['mp3'], onplay: () => { console.log('Audio is playing'); }, onend: () => { console.log('Audio has finished playing'); resolve(); // 音频结束后resolve Promise }, onloaderror: (id, err) => { console.error('音频加载失败:', err); reject(err); } }); sound.play(); } else { const error = new Error('语音合成失败'); console.error(error.message); reject(error); } }).catch(err => { console.error('语音合成错误:', err); reject(err); }); } catch (error) { console.error('语音合成错误:', error); reject(error); } }); }
2. 重构renderer.js的流程控制逻辑
添加状态锁管理,统一处理定时器和音频操作:
const { speak, updateVoiceConfig, pauseAudio, stopAudio, resumeAudio } = require('./polly/pollyServices.js'); let currentIndex = 0; let intervalId; let data; let time = 5; let isProcessing = false; // 标记是否正在处理当前单词流程,防止并行操作 async function fetchData() { try { const response = await fetch('http://localhost:3005/randomwords', { method: 'GET', headers: { 'Content-Type': 'application/json' } }); data = await response.json(); console.log(data); return data; } catch (error) { console.error('获取数据失败:', error); return null; } } function checkFetchApiStatus() { if (!data) { document.getElementById('error').innerText = '无法从API获取数据'; return; } } async function loadDataIfNeeded() { if (currentIndex === 0 || !data || data.length === 0) { data = await fetchData(); } } function displayWord() { const word = data[currentIndex]; const wordElement = document.getElementById('word'); let content = ""; for (const key in word) { if (Object.hasOwnProperty.call(word, key) && key !== "wordID") { content += `${word[key]}\n`; } } wordElement.innerText = content.trim(); } // 封装当前单词的播放流程 async function playCurrentWordSequence() { if (isProcessing) return; isProcessing = true; try { await loadDataIfNeeded(); checkFetchApiStatus(); if (!data) return; displayWord(); await speak(data[currentIndex].english); await new Promise(resolve => setTimeout(resolve, 3000)); await speak(data[currentIndex].example_sentence); } finally { isProcessing = false; // 无论成功失败都释放状态锁 } } async function nextWord() { await playCurrentWordSequence(); currentIndex = (currentIndex + 1) % data.length; intervalId = setTimeout(nextWord, time * 1000); } function setupListeners() { const intervalInput = document.getElementById('intervalInput'); const voiceSelect = document.getElementById('voiceSelect'); intervalInput.addEventListener('input', () => { time = parseInt(intervalInput.value) || 5; // 确保是数字,默认5秒 }); voiceSelect.addEventListener('change', () => { const selectedVoice = voiceSelect.value; updateVoiceConfig(selectedVoice); }); const repeatButton = document.getElementById('repeatButton'); repeatButton.addEventListener('click', async function () { // 清除原有自动切换定时器 if (intervalId) { clearTimeout(intervalId); intervalId = null; } // 停止当前播放的音频 stopAudio(); // 重复播放当前单词序列 await playCurrentWordSequence(); // 重启自动切换流程 intervalId = setTimeout(nextWord, time * 1000); }); } window.onload = function() { setupListeners(); nextWord(); };
3. 关键修复点说明
- 音频等待机制:修改
speak函数返回Promise,确保await能等待音频完全播放结束,避免UI和音频流程脱节 - 状态锁
isProcessing:防止重复点击或自动流程并行执行,避免冲突 - 定时器管理:点击重复按钮时先清除原有自动定时器,完成重复播放后再重启,确保流程统一
- 音频终止:点击重复时立即停止当前音频,避免新旧音频重叠
内容的提问来源于stack exchange,提问作者Minor Marin
相关产品推荐
相关产品推荐

