Skip to content

Commit 2d14e07

Browse files
authored
Merge pull request #68 from xcc-zach/refactor/api
feature: add PyWebRTC speech enhancer far support
2 parents fd3b063 + 85418a6 commit 2d14e07

11 files changed

Lines changed: 767 additions & 11 deletions

File tree

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ celerybeat.pid
128128
*.sage.py
129129

130130
# Environments
131-
.env
131+
*.env
132132
.venv
133133
env/
134134
venv/
@@ -184,4 +184,4 @@ examples/sample_server/node_modules/
184184
# User defined
185185
/logs/
186186
server_configs/
187-
/data/
187+
/data/
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Speech Enhancer Design
2+
3+
```python
4+
class SpeechEnhancer(ABC):
5+
"""Abstract base class for speech enhancement engines."""
6+
7+
@abstractmethod
8+
def enhance(self, audio: bytes, far: bytes) -> bytes:
9+
...
10+
11+
def flush(self) -> bytes:
12+
return b""
13+
14+
async def async_enhance(
15+
self,
16+
audio: bytes,
17+
far: bytes,
18+
) -> bytes:
19+
...
20+
21+
async def async_flush(self) -> bytes:
22+
...
23+
24+
@abstractmethod
25+
def reset(self) -> None:
26+
...
27+
28+
@abstractmethod
29+
def clone(self) -> "SpeechEnhancer":
30+
...
31+
```
32+
33+
## Audio Format
34+
35+
`SpeechEnhancer` input and output use:
36+
37+
- PCM 16-bit
38+
- Mono
39+
- 16000 Hz
40+
- Raw PCM bytes without a WAV header
41+
42+
`audio` is the near-end microphone signal. `far` is the far-end reference signal, usually derived from the TTS audio being played to the user, and is used for acoustic echo cancellation.
43+
44+
The upstream audio pipeline always provides `far` and guarantees that:
45+
46+
- `far` uses the same audio format as `audio`
47+
- `len(far) == len(audio)`
48+
- both buffers describe the near-end input and far-end reference for the same time window
49+
50+
## `enhance` and `async_enhance`
51+
52+
The service layer primarily calls `async_enhance`. If the underlying implementation only has a synchronous API, implement `enhance` and reuse the base class thread-pool wrapper for `async_enhance`.
53+
54+
If the underlying implementation is asynchronous or remote, prefer implementing `async_enhance` directly and wrapping it for `enhance`.
55+
56+
```python
57+
import asyncio
58+
59+
def enhance(self, audio: bytes, far: bytes) -> bytes:
60+
return self._run_coro(self.async_enhance(audio, far=far))
61+
62+
def _run_coro(self, coro: "asyncio.Future[bytes]") -> bytes:
63+
loop = asyncio.new_event_loop()
64+
try:
65+
return loop.run_until_complete(coro)
66+
finally:
67+
loop.close()
68+
```
69+
70+
## Meaning of `far`
71+
72+
`far` is a required interface argument and enables enhancement engines that need a far-end reference, such as acoustic echo cancellation.
73+
74+
- When nothing is being played, the service layer passes a silent `far` buffer with the same length as `audio`.
75+
- When TTS is being played, the service layer reads a same-length slice from the TTS reference buffer.
76+
- If the reference buffer is short, the service layer pads silence on the right and still preserves equal length.
77+
- FastEnhancer accepts the interface argument but ignores `far` in both local and remote modes.
78+
- A concrete enhancer implementation that targets an echo-cancellation service may use `far`.
79+
80+
Therefore, implementations may assume that `far` has the same byte length as `audio`. The upstream service pipeline owns this invariant, so concrete enhancers do not need to check it again.
81+
82+
## How the Service Layer Builds the Far-End Reference
83+
84+
`EnhancerManager` consumes `AudioFrameReceived` and publishes `EnhancedAudioFrameReceived`. When a `SpeechEnhancer` model is configured, each user audio frame is processed like this:
85+
86+
```python
87+
far = far_reference.take(len(audio))
88+
enhanced = await enhancer.async_enhance(audio, far=far)
89+
```
90+
91+
The far-end reference comes from `TTSChunkReady`. The service layer copies the TTS chunk that is about to be sent to the client, converts it to the speech-enhancer format, and writes it into a reference buffer.
92+
93+
Current conversion rules:
94+
95+
- Treat input TTS chunks as PCM 16-bit mono
96+
- Resample from `TTSChunkReady.sample_rate` to 16000 Hz
97+
- Store the converted audio in a bounded FIFO buffer
98+
- Keep up to 5 seconds by default, configurable with `far_reference_buffer_seconds`
99+
100+
When user audio arrives, the service layer reads a far-end reference buffer with the same byte length as the current `audio`. If no reference is available, it returns same-length silence.
101+
102+
## Role of `TTSChunkPlayed`
103+
104+
`TTSChunkPlayed` means the frontend has confirmed that a TTS chunk finished playback. The current event does not include a `chunk_id`, client playback timestamp, or sample offset, so it cannot provide sample-level alignment.
105+
106+
The service layer uses it to discard already played reference audio that microphone frames did not consume. This prevents stale TTS audio from being used as `far` when the user waits until TTS playback completes before speaking.
107+
108+
More precise alignment should come from future client-side playback reference feedback or timestamped playback events.
109+
110+
## FastEnhancer Behavior
111+
112+
`FastEnhancer` implements the `SpeechEnhancer` interface but does not use `far`.
113+
This is true for both local ONNX mode and remote FastEnhancer WebSocket mode.
114+
Remote FastEnhancer continues to use its legacy binary PCM protocol.
115+
116+
`PyWebRTCAudio` is the concrete adapter for the pywebrtc-audio service. Its
117+
constructor only accepts `base_url`, and it sends the upstream-provided `audio`
118+
and `far` to the service's `/v1/stream` JSON WebSocket endpoint.
119+
120+
## `flush`
121+
122+
`flush` / `async_flush` drains tail audio buffered inside the enhancer. The service layer inserts a flush barrier after `VADSpeechEnd` so all earlier audio frames are enhanced and dispatched downstream before the flush runs.
123+
124+
For remote FastEnhancer mode, `flush` sends any locally pending padded audio and then uses the remote FastEnhancer flush command.
125+
126+
## `reset` and `clone`
127+
128+
`reset` should clear the current session's streaming state, such as model caches, input/output buffers, remote connections, and pending audio. It should not reload model weights.
129+
130+
`clone` should create an independent runtime instance for a new session. Clones may share weights, configuration, or read-only resources, but must not share streaming buffers, remote connections, pending audio, or session state.
131+
132+
See [Semantics of `clone()` and `reset()` on Model Objects](model_clone_reset.md).
133+
134+
## Implementation Suggestions
135+
136+
- Keep output length aligned with input `audio` whenever possible, so downstream VAD and ASR timelines do not drift.
137+
- Do not try to infer TTS alignment inside the model interface; length padding and far selection belong to the service layer.
138+
- If the implementation requires `far`, it may assume the upstream pipeline already matched its length with `audio`.
139+
- If the implementation does not support `far`, ignore the argument but do not fail on a silent reference.
140+
- Remote implementations should clear pending audio on `reset` and connection rebuilds.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# 语音增强设计
2+
3+
```python
4+
class SpeechEnhancer(ABC):
5+
"""语音增强引擎抽象基类。"""
6+
7+
@abstractmethod
8+
def enhance(self, audio: bytes, far: bytes) -> bytes:
9+
...
10+
11+
def flush(self) -> bytes:
12+
return b""
13+
14+
async def async_enhance(
15+
self,
16+
audio: bytes,
17+
far: bytes,
18+
) -> bytes:
19+
...
20+
21+
async def async_flush(self) -> bytes:
22+
...
23+
24+
@abstractmethod
25+
def reset(self) -> None:
26+
...
27+
28+
@abstractmethod
29+
def clone(self) -> "SpeechEnhancer":
30+
...
31+
```
32+
33+
## 音频格式约定
34+
35+
`SpeechEnhancer` 的输入和输出均使用:
36+
37+
- PCM 16-bit
38+
- 单声道
39+
- 16000 Hz
40+
- 裸 PCM 字节流,不包含 WAV 头
41+
42+
`audio` 表示近端麦克风音频。`far` 表示远端参考音频,通常来自系统正在播放给用户的 TTS 音频,用于声学回声消除。
43+
44+
上游音频管线总是提供 `far`,并保证:
45+
46+
- `far``audio` 使用相同音频格式
47+
- `len(far) == len(audio)`
48+
- 二者描述同一段时间窗口内的近端输入与远端参考
49+
50+
## `enhance``async_enhance`
51+
52+
服务层实际优先调用 `async_enhance`。如果底层实现只有同步 API,可以实现 `enhance`,并复用基类提供的 `async_enhance` 线程池包装。
53+
54+
如果底层实现本身是异步或远程服务,最佳实践是直接实现 `async_enhance`,再用同步包装实现 `enhance`
55+
56+
```python
57+
import asyncio
58+
59+
def enhance(self, audio: bytes, far: bytes) -> bytes:
60+
return self._run_coro(self.async_enhance(audio, far=far))
61+
62+
def _run_coro(self, coro: "asyncio.Future[bytes]") -> bytes:
63+
loop = asyncio.new_event_loop()
64+
try:
65+
return loop.run_until_complete(coro)
66+
finally:
67+
loop.close()
68+
```
69+
70+
## `far` 的语义
71+
72+
`far` 是必传接口参数,用于支持带远端参考的语音增强或回声消除。
73+
74+
- 没有远端播放时,服务层会传入与 `audio` 等长的静音 `far`
75+
- 有 TTS 播放时,服务层会从 TTS 参考缓冲中取出与当前 `audio` 等长的片段。
76+
- 参考缓冲不足时,服务层会在右侧补静音,仍然保证长度一致。
77+
- FastEnhancer 会接收接口参数,但本地模式和远程模式都会忽略 `far`
78+
- 面向回声消除服务的具体 enhancer 实现可以使用 `far`
79+
80+
因此,语音增强实现可以安全地假设:`far``audio` 等长。该约束由上游服务管线负责,具体 enhancer 实现不需要重复检查。
81+
82+
## 服务层如何生成远端参考
83+
84+
`EnhancerManager` 消费 `AudioFrameReceived`,并发布 `EnhancedAudioFrameReceived`。当存在 `SpeechEnhancer` 模型时,它会在处理每个用户音频帧时调用:
85+
86+
```python
87+
far = far_reference.take(len(audio))
88+
enhanced = await enhancer.async_enhance(audio, far=far)
89+
```
90+
91+
远端参考来自 `TTSChunkReady` 事件。服务层会把将要发送给客户端的 TTS chunk 复制一份,转换为语音增强接口要求的格式后写入参考缓冲。
92+
93+
当前转换规则为:
94+
95+
- 输入 TTS chunk 视为 PCM 16-bit 单声道
96+
- 根据 `TTSChunkReady.sample_rate` 重采样到 16000 Hz
97+
- 写入一个有限长度的 FIFO 缓冲
98+
- 默认缓冲上限为 5 秒,可通过 `far_reference_buffer_seconds` 配置
99+
100+
当用户音频到达时,服务层从该缓冲取出与当前 `audio` 等长的远端参考。没有可用参考时返回等长静音。
101+
102+
## `TTSChunkPlayed` 的作用
103+
104+
`TTSChunkPlayed` 表示前端确认某个 TTS chunk 已播放完成。当前事件不携带 `chunk_id`、客户端播放时间戳或样本偏移,因此它不能提供样本级对齐。
105+
106+
服务层将其用于清理已经播放但尚未被麦克风帧消耗的旧参考音频,避免用户等 TTS 播完后再说话时仍然拿旧 TTS 作为 `far`
107+
108+
更精确的对齐应由未来的客户端播放参考回传或带时间戳的播放事件实现。
109+
110+
## FastEnhancer 行为
111+
112+
`FastEnhancer` 实现 `SpeechEnhancer` 接口,但不使用 `far`。本地 ONNX 模式和远程 FastEnhancer WebSocket 模式都是如此。远程 FastEnhancer 仍然使用旧的二进制 PCM 协议。
113+
114+
`PyWebRTCAudio` 是面向 pywebrtc-audio 服务的具体适配器。它的构造参数仅包含 `base_url`,并把上游提供的 `audio``far` 发送到服务的 `/v1/stream` JSON WebSocket 接口。
115+
116+
## `flush`
117+
118+
`flush` / `async_flush` 用于排出增强器内部缓冲的尾部音频。服务层会在 `VADSpeechEnd` 后插入 flush barrier,确保所有更早的音频帧先完成增强和下游分发。
119+
120+
对远程 FastEnhancer 模式,`flush` 会发送本地 pending audio 的补齐帧,然后使用远程 FastEnhancer 的 flush 命令。
121+
122+
## `reset``clone`
123+
124+
`reset` 应清空当前会话的流式状态,例如模型 cache、输入输出缓冲、远程连接和 pending audio,但不应重新加载权重。
125+
126+
`clone` 应为新会话创建独立运行时实例。多个克隆可以共享权重、配置或只读资源,但不能共享流式缓冲、远程连接、pending audio 或会话状态。
127+
128+
请参阅[模型对象的 `clone()``reset()` 语义](model_clone_reset.zh.md)
129+
130+
## 实现建议
131+
132+
- 输出长度应尽量与输入 `audio` 长度一致,避免下游 VAD/ASR 时间轴漂移。
133+
- 不要在模型接口层自行猜测 TTS 对齐;长度补齐和 far 选择由服务层负责。
134+
- 如果实现需要 `far`,可以假设上游管线已经将其长度与 `audio` 对齐。
135+
- 如果实现不支持 `far`,可以忽略该参数,但不应因为收到静音 `far` 而失败。
136+
- 远程实现应在 `reset` 和连接重建时清空 pending audio。

docs/docs/supported_models.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,18 @@ Turn detector is used to determine whether the user has finished speaking and de
117117
[Original Repository](https://github.com/aask1357/fastenhancer)
118118

119119
</details>
120+
121+
<details markdown="1">
122+
<summary>PyWebRTCAudio</summary>
123+
124+
**Dependency:** `pip install "xtalk[pywebrtc-audio] @ git+https://github.com/xcc-zach/xtalk.git@main"`
125+
126+
**Path:** [`src/xtalk/models/speech_enhancer/pywebrtc_audio.py`](https://github.com/xcc-zach/xtalk/blob/main/src/xtalk/models/speech_enhancer/pywebrtc_audio.py)
127+
128+
**Config params:** only `base_url`.
129+
130+
[Quick Start](https://github.com/xcc-zach/xtalk-pywebrtc-audio)
131+
132+
[Original Repository](https://github.com/strands-labs/pywebrtc-audio)
133+
134+
</details>

docs/docs/supported_models.zh.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,19 @@ Turn detector 用于判断用户是否已经说完,并决定系统何时开始
116116

117117
[原始仓库](https://github.com/aask1357/fastenhancer)
118118

119-
</details>
119+
</details>
120+
121+
<details markdown="1">
122+
<summary>PyWebRTCAudio</summary>
123+
124+
**依赖:** `pip install "xtalk[pywebrtc-audio] @ git+https://github.com/xcc-zach/xtalk.git@main"`
125+
126+
**路径:** [`src/xtalk/models/speech_enhancer/pywebrtc_audio.py`](https://github.com/xcc-zach/xtalk/blob/main/src/xtalk/models/speech_enhancer/pywebrtc_audio.py)
127+
128+
**配置参数:**`base_url`
129+
130+
[快速开始](https://github.com/xcc-zach/xtalk-pywebrtc-audio)
131+
132+
[原始仓库](https://github.com/strands-labs/pywebrtc-audio)
133+
134+
</details>

docs/mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ plugins:
5757
'Service Configuration': 服务配置项
5858
'ASR Design': ASR设计
5959
'TTS Design': TTS设计
60+
'Speech Enhancer Design': 语音增强设计
6061
'VAD Design': VAD设计
6162
'Turn Detector Design': Turn Detector设计
6263
'Model clone() and reset()': 模型对象的clone()与reset()语义
@@ -83,6 +84,7 @@ nav:
8384
- 'Service Configuration': docs/service_config.md
8485
- 'ASR Design': docs/asr_design.md
8586
- 'TTS Design': docs/tts_design.md
87+
- 'Speech Enhancer Design': docs/speech_enhancer_design.md
8688
- 'VAD Design': docs/vad_design.md
8789
- 'Turn Detector Design': docs/turn_detector_design.md
8890
- 'Model clone() and reset()': docs/model_clone_reset.md

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ fast-enhancer = [
135135
"onnxruntime",
136136
"websockets",
137137
]
138+
pywebrtc-audio = [
139+
"websockets",
140+
]
138141
rubberband = [
139142
"pyrubberband"
140143
]

0 commit comments

Comments
 (0)