You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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} ;

问题根源

  1. speak函数未返回音频播放完成的Promise,导致await speak()无法等待音频结束,流程提前推进
  2. 自动切换的定时器intervalId未在重复操作时清除,原有自动流程与重复操作并行执行
  3. 重复按钮逻辑未处理当前正在播放的音频和流程状态,导致异步操作冲突

修复方案

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.28 13:37:04