固定frame rate的windows-event-loop

本文讨论了Windows游戏或工具中常见的事件循环问题,并提供了一种更优的实现方式。通过使用GetMessage而非PeekMessage,可以避免CPU空转并合理利用资源。此外,还介绍了如何通过调整渲染调用频率及采用可中断等待的方式来提高用户体验。

Link: http://www.mindcontrol.org/~hplus/misc/windows-event-loop.html

 

They typical Windows event loop in a game or tool may look something like:

  while( running ) {
    if( PeekMessage( ... ) ) {
      TranslateMessage( ... );  
      DispatchMessage( ... );
    }
    Render();
  }


Bad Code

There are at least two problems with that:

1) You're calling PeekMessage(), which immediately returns, so the loop will just spin. GetMessage() will wait until there's a message available (and yield the CPU).

2) You're calling Render() once per windows message. This is really bad, as certain windows transactions take many messages. Your look should look something like:

  HANDLE h = (HANDLE)CreateEvent( 0, false, false, 0 );
  while( running ) {
    while( PeekMessage( ... ) ) {
      TranslateMessage( ... );
      DispatchMessage( ... );
    }
    timeout = CalculateNextFrameTimeout();
    if( timeout <= 0 ) { // time to render?
      Render();
      timeout = CalculateNextFrameTimeout();
    }
    // use an alertable wait
    MsgWaitForMultipleObjectsEx( 1, &h, timeout, QS_ALLEVENTS, 
        MWMO_ALERTABE|MWMO_INPUTAVAILABLE );  
  }
  CloseHandle( h );


Good Code

This code assumes that Render() does something that makes the next call to CalculateNextFrameTimeout() return greater than 0 for a while :-)

This code will keep the frame rate limited, and give up any CPU not needed. It will efficiently dispatch cascades of Windows events. And, most importantly, it will immediately wake up and dispatch any events generated by the user (such as from mousemove, keydown, etc) because it uses an alterable sleep. Sleep() is not alertable.

 

<think>我们参考用户提供的后端代码,可以看到后端期望接收的是Opus编码的音频帧。但是,用户的前端代码目前发送的是PCM数据(Int16Array转换成的Uint8Array),并没有进行Opus编码。因此,我们需要在前端对PCM数据进行Opus编码。 然而,前端实现Opus编码可以使用WebAssembly版本的Opus编码器。这里我们可以使用一个现成的库:`opus-encoder`。 步骤: 1. 安装`opus-encoder`:`npm install @evan/opus-encoder` 2. 在组件中引入并创建Opus编码器实例。 3. 在音频处理回调中,将PCM数据(Int16Array)通过Opus编码器编码为Opus帧。 4. 发送Opus帧(Uint8Array)给后端。 注意:编码器需要指定的参数(采样率、声道数、帧大小)要与后端期望的一致。根据后端代码,采样率为16000Hz,单声道,帧大小(样本数)为960(即60ms)。 但是,`opus-encoder`库默认的采样率是48000Hz。因此,我们需要使用支持16000Hz的编码器。查看该库的文档,它支持多种采样率(包括16000Hz)。 修改计划: - 在`startRecording`函数中,初始化Opus编码器。 - 在音频处理回调中,不再发送PCM数据,而是将PCM数据(Int16Array)编码为Opus帧,然后发送Opus帧。 - 注意:由于Opus编码器一次编码一帧(固定样本数),所以我们需要确保每次传递给编码器的数据正好是960个样本(即60ms的数据)。因此,需要缓冲机制来累积样本直到达到一帧。 由于我们设置的采样率是16000Hz,每帧960个样本,所以每帧时长60ms(16000Hz * 0.06s = 960样本)。 具体修改: 1. 引入OpusEncoder: ```typescript import { OpusEncoder } from '@evan/opus-encoder'; ``` 2. 在组件中定义编码器变量: ```typescript const opusEncoder = ref<OpusEncoder | null>(null); ``` 3. 在`startRecording`函数中初始化编码器: ```typescript opusEncoder.value = new OpusEncoder({ sampleRate: SAMPLE_RATE, // 16000 channels: CHANNELS, // 1 frameSize: SAMPLES_PER_FRAME // 960 }); ``` 4. 修改音频处理回调:当累积够一帧(960个样本)时,进行编码并发送。 注意:我们之前已经将数据缓冲在`frameBuffer`中,并每次取出960个样本。现在,我们将这960个样本(Int16Array)进行编码。 将原来的: ```typescript // 2. 创建完全匹配图片格式的数据对象 const opusData = createOpusDataObject(pcmInt16); // ... 然后发送 opusData.data ``` 改为: ```typescript if (opusEncoder.value) { // 编码为Opus帧(Uint8Array) const opusFrame = opusEncoder.value.encode(pcmInt16); // 发送Opus帧 if (isConnected?.value && isRecording.value) { send(opusFrame, 2); // 假设第二个参数2表示二进制帧 lastSentSize.value = opusFrame.length; frameCount.value++; } } ``` 5. 在停止录音时,销毁编码器: ```typescript if (opusEncoder.value) { opusEncoder.value.destroy(); opusEncoder.value = null; } ``` 6. 移除之前模拟Opus格式的`createOpusDataObject`函数,因为现在发送的是真正的Opus帧。 注意:由于我们使用了外部库,需要确保在组件卸载时清理编码器。 另外,后端代码中处理的是Opus帧,因此我们发送的Opus帧应该是原始Opus包(不含Ogg封装)。`@evan/opus-encoder`的`OpusEncoder`默认生成的就是原始Opus包。 这样修改后,前端发送的就是后端期望的Opus格式。 但是,我们还需要注意:在开始录音时发送的`startData`中,我们指定了`frameSize: SAMPLES_PER_FRAME`(960),而后端可能根据这个值来解析。同时,在发送音频帧时,我们使用相同的帧大小。 另外,后端代码中使用了`audioProcesser.DecoderFloat32`来解码Opus帧,说明后端期望的是Opus编码。因此,修改后应该可以匹配。 关于安装:如果项目中没有安装`@evan/opus-encoder`,需要安装。如果项目不支持,可能需要考虑其他方案,但用户已经要求使用Opus编码。 最后,由于我们引入了新的库,需要确保在打包时能够正确处理。 代码调整总结: - 安装依赖 - 引入并初始化Opus编码器 - 将PCM数据编码为Opus帧并发送 - 清理编码器资源 注意:编码器的初始化是异步的吗?查看文档,`OpusEncoder`的初始化是同步的,但是内部会加载WebAssembly模块,这可能是异步的。因此,我们需要等待编码器准备好。 根据`@evan/opus-encoder`的文档,它返回一个Promise,所以我们需要使用`await`: 修改初始化代码: ```typescript opusEncoder.value = await OpusEncoder.create({ sampleRate: SAMPLE_RATE, channels: CHANNELS, frameSize: SAMPLES_PER_FRAME }); ``` 因此,`startRecording`函数需要标记为`async`,并且在初始化编码器时使用`await`。 另外,需要处理初始化失败的情况。 调整后的`startRecording`函数中编码器初始化部分: ```typescript try { opusEncoder.value = await OpusEncoder.create({ sampleRate: SAMPLE_RATE, channels: CHANNELS, frameSize: SAMPLES_PER_FRAME }); } catch (err) { console.error('初始化Opus编码器失败:', err); errorMessage.value = '初始化音频编码器失败'; // 清理并返回 stopRecording(); return; } ``` 注意:由于我们使用了`await`,编码器初始化完成后才会继续。 另外,`OpusEncoder`的`encode`方法要求输入是`Int16Array`,并且长度等于帧大小(960)。我们已经在每次取960个样本,所以满足条件。 最后,发送的数据类型是`Uint8Array`,这是WebSocket发送二进制数据所支持的。 这样,前端发送的就是后端期望的Opus帧了。 修改后的代码示例(部分):</think>要确保前端正确封装 Opus 音频数据并与后端格式匹配,需要遵循以下关键步骤: ### 一、核心实现方案 ```typescript import { OpusEncoder } from 'opus-encoder'; // 初始化 Opus 编码器 (16000Hz, 单声道) const opusEncoder = await OpusEncoder.create({ sampleRate: 16000, channels: 1, frameSize: 960 // 60ms 帧 }); // 音频处理回调 scriptProcessor.value.onaudioprocess = (event) => { const inputData = event.inputBuffer.getChannelData(0); // 1. 转换为 Int16 PCM const pcmInt16 = new Int16Array(inputData.length); for (let i = 0; i < inputData.length; i++) { const s = Math.max(-1, Math.min(1, inputData[i])); pcmInt16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; } // 2. 编码为 Opus 帧 const opusFrame = opusEncoder.encode(pcmInt16); // 3. 封装为后端要求的格式 const payload = { session_id: formData.session_id, type: 'audio', timestamp: Date.now(), data: Array.from(opusFrame) // Uint8Array -> Array }; // 4. 通过 WebSocket 发送 if (isConnected.value) { send(JSON.stringify(payload)); } }; ``` ### 二、关键匹配点 1. **帧格式匹配**: - 采样率:16000Hz(与后端 `audioFormat.SampleRate` 一致) - 帧大小:960 样本 = 60ms(与后端 `frameSize` 匹配) - 单声道(与后端处理逻辑一致) 2. **数据结构匹配**: ```typescript // 后端期望的格式 interface OpusPayload { session_id: string; // 会话ID type: 'audio'; // 固定类型 timestamp: number; // 时间戳(ms) data: number[]; // Opus帧数据(Uint8Array转Array) } ``` 3. **传输协议**: ```typescript // WebSocket发送控制命令 const sendControlCommand = (state: 'start' | 'stop') => { send(JSON.stringify({ session_id: formData.session_id, type: 'control', state: state, sampleRate: 16000, frameSize: 960 })); }; // 开始录音时 sendControlCommand('start'); // 停止录音时 sendControlCommand('stop'); ``` ### 三、完整工作流程 ```mermaid sequenceDiagram participant Frontend as 前端 participant Backend as 后端 Frontend->>Backend: 发送control/start命令 Backend-->>Frontend: 确认准备就绪 loop 每60ms音频帧 Frontend->>Frontend: 采集960样本(16000Hz) Frontend->>Frontend: PCM转Int16 Frontend->>Frontend: Opus编码 Frontend->>Backend: 发送音频帧payload end Frontend->>Backend: 发送control/stop命令 Backend->>Backend: 处理完整音频流 ``` ### 四、安装依赖 ```bash npm install opus-encoder ``` ### 五、注意事项 1. **帧对齐**: ```typescript // 确保每次传递960样本 while (buffer.length >= 960) { const frame = buffer.slice(0, 960); buffer = buffer.slice(960); // 处理帧... } ``` 2. **时间戳同步**: ```typescript // 使用相对时间戳(从录音开始) const startTime = Date.now(); // 在每帧中计算 const timestamp = Date.now() - startTime; ``` 3. **错误处理**: ```typescript try { const opusFrame = opusEncoder.encode(pcmData); } catch (err) { console.error('Opus编码失败:', err); // 重试或跳过无效帧 } ``` ### 六、后端兼容性调整建议 1. 在Go后端添加对前端数据结构的解析: ```go type AudioFrame struct { SessionID string `json:"session_id"` Type string `json:"type"` Timestamp int64 `json:"timestamp"` Data []byte `json:"data"` } func handleAudioFrame(frame AudioFrame) { opusData := frame.Data // 后续解码处理... } ``` 2. 时间戳对齐: ```go // 计算音频位置(ms) audioPos := frame.Timestamp - sessionStartTime ``` ### 七、性能优化 1. **Web Worker**:将Opus编码移入Worker避免阻塞UI 2. **内存复用**:重用ArrayBuffer减少GC 3. **动态比特率**:根据网络质量调整编码参数 ```typescript opusEncoder.setBitrate(networkQuality > 80 ? 24000 : 16000); ``` --- ### 相关问题 1. 如何在前端实现音频数据的动态比特率调整? 2. Web Worker 中处理音频编码的最佳实践是什么? 3. 如何在前端实现音频帧丢失重传机制? 4. Opus 编码参数(复杂度、比特率)对语音识别准确率有何影响? 5. 在弱网环境下如何优化音频传输的实时性?
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值