Skip to content

Commit 6fd136f

Browse files
leafliberBegoniaHe
authored andcommitted
fix: resample and downmix WAV files for Tencent Silk encoding (AstrBotDevs#9100)
* fix: resample and downmix WAV files for Tencent Silk encoding * fix: improve WAV to Tencent Silk conversion by handling sample width and resampling
1 parent babf459 commit 6fd136f

2 files changed

Lines changed: 120 additions & 9 deletions

File tree

astrbot/core/utils/tencent_record_helper.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
"""Tencent Silk audio conversion helpers."""
22

33
import asyncio
4+
import audioop
45
import os
56
import subprocess
67
import wave
78
from io import BytesIO
89

910
from astrbot.core import logger
1011

12+
# The SILK SDK only supports these rates
13+
_PYSILK_SUPPORTED_RATES = frozenset({8000, 12000, 16000, 24000, 32000, 48000})
14+
1115

1216
async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str:
1317
"""Decode a Tencent Silk file to 24 kHz mono PCM WAV.
@@ -69,16 +73,27 @@ async def wav_to_tencent_silk(wav_path: str, output_path: str) -> float:
6973

7074
with wave.open(wav_path, "rb") as wav:
7175
rate = wav.getframerate()
72-
frames = wav.getnframes()
73-
pcm_data = wav.readframes(frames)
76+
channels = wav.getnchannels()
77+
sampwidth = wav.getsampwidth()
78+
pcm_data = wav.readframes(wav.getnframes())
79+
80+
# Downmix to mono, resample to 24 kHz if needed, and convert to 16-bit PCM
81+
# (pysilk only accepts 16-bit linear PCM)
82+
if channels == 2:
83+
pcm_data = audioop.tomono(pcm_data, sampwidth, 0.5, 0.5)
84+
if rate not in _PYSILK_SUPPORTED_RATES:
85+
pcm_data, _ = audioop.ratecv(pcm_data, sampwidth, 1, rate, 24000, None)
86+
rate = 24000
87+
if sampwidth != 2:
88+
pcm_data = audioop.lin2lin(pcm_data, sampwidth, 2)
7489

7590
input_io = BytesIO(pcm_data)
7691
output_io = BytesIO()
7792
# tencent=True makes pysilk emit the QQ-compatible 0x02-prefixed SILK stream.
7893
pysilk.encode(input_io, output_io, rate, rate, tencent=True)
7994
with open(output_path, "wb") as f:
8095
f.write(output_io.getvalue())
81-
return frames / rate if rate else 0
96+
return len(pcm_data) / (2 * rate) if rate else 0
8297

8398

8499
async def convert_to_pcm_wav(input_path: str, output_path: str) -> str:

tests/test_media_utils.py

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import math
33
import os
44
import struct
5+
import sys
56
import wave
67
from io import BytesIO
78
from pathlib import Path
@@ -646,19 +647,39 @@ def test_path_mapping_accepts_standard_and_legacy_file_uri(tmp_path):
646647

647648

648649
@pytest.mark.asyncio
649-
async def test_tencent_silk_encoding_uses_pysilk_tencent_format(tmp_path, monkeypatch):
650+
@pytest.mark.parametrize(
651+
"rate, channels",
652+
[
653+
(24000, 1), # supported, no resample
654+
(44100, 1), # unsupported rate, triggers resample
655+
(22050, 1), # unsupported rate, triggers resample
656+
(48000, 2), # stereo at supported rate, triggers downmix
657+
(44100, 2), # stereo + unsupported rate, triggers both
658+
],
659+
ids=["24k-mono", "44.1k-mono", "22.05k-mono", "48k-stereo", "44.1k-stereo"],
660+
)
661+
async def test_tencent_silk_encoding_uses_pysilk_tencent_format(
662+
rate, channels, tmp_path, monkeypatch
663+
):
664+
"""Real pysilk end-to-end across sample rates that previously failed.
665+
666+
44100 Hz was the regression trigger: pysilk rejects it with
667+
ENC_INPUT_INVALID_NO_OF_SAMPLES. The fix resamples to 24 kHz mono via
668+
audioop.ratecv before encoding.
669+
"""
650670
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(tmp_path))
651671
wav_path = tmp_path / "tone.wav"
652672
silk_path = tmp_path / "tone.silk"
653-
rate = 24000
654-
frames = int(rate * 0.2)
673+
secs = 0.2
674+
frames = int(rate * secs)
655675
with wave.open(str(wav_path), "wb") as wav:
656-
wav.setnchannels(1)
676+
wav.setnchannels(channels)
657677
wav.setsampwidth(2)
658678
wav.setframerate(rate)
659679
for i in range(frames):
660680
sample = int(0.2 * 32767 * math.sin(2 * math.pi * 440 * i / rate))
661-
wav.writeframesraw(struct.pack("<h", sample))
681+
for _ in range(channels):
682+
wav.writeframesraw(struct.pack("<h", sample))
662683

663684
duration = await wav_to_tencent_silk(str(wav_path), str(silk_path))
664685
silk_bytes = silk_path.read_bytes()
@@ -672,7 +693,82 @@ async def test_tencent_silk_encoding_uses_pysilk_tencent_format(tmp_path, monkey
672693
assert resolved.format == "tencent_silk"
673694
assert resolved.mime_type == "audio/silk"
674695

675-
assert duration == pytest.approx(0.2)
696+
assert duration == pytest.approx(secs, abs=0.05)
676697
assert silk_bytes.startswith(b"\x02#!SILK_V3")
677698
assert resolved_silk_bytes.startswith(b"\x02#!SILK_V3")
678699
assert not resolved_silk_path.exists()
700+
701+
702+
def _make_wav(path, rate, channels=1, secs=0.2, freq=440):
703+
"""Write a short sine-tone WAV at the given rate/channels."""
704+
nframes = int(rate * secs)
705+
with wave.open(str(path), "wb") as wav:
706+
wav.setnchannels(channels)
707+
wav.setsampwidth(2)
708+
wav.setframerate(rate)
709+
for i in range(nframes):
710+
sample = int(0.2 * 32767 * math.sin(2 * math.pi * freq * i / rate))
711+
for _ in range(channels):
712+
wav.writeframesraw(struct.pack("<h", sample))
713+
714+
715+
class _FakePysilk:
716+
"""Stand-in for the ``pysilk`` module that records encode() calls."""
717+
718+
def __init__(self):
719+
self.calls = []
720+
721+
def encode(self, input_io, output_io, sample_rate, bit_rate, tencent=True):
722+
self.calls.append({"sample_rate": sample_rate, "tencent": tencent})
723+
output_io.write(b"\x02#!SILK_V3")
724+
725+
726+
@pytest.mark.asyncio
727+
async def test_wav_to_tencent_silk_resamples_unsupported_rate(tmp_path, monkeypatch):
728+
"""44100 Hz input must be resampled to 24 kHz before pysilk.encode."""
729+
fake = _FakePysilk()
730+
monkeypatch.setitem(sys.modules, "pysilk", fake)
731+
732+
wav_path = tmp_path / "tts_44100.wav"
733+
_make_wav(wav_path, 44100)
734+
735+
silk_path = tmp_path / "out.silk"
736+
await wav_to_tencent_silk(str(wav_path), str(silk_path))
737+
738+
assert len(fake.calls) == 1
739+
assert fake.calls[0]["sample_rate"] == 24000
740+
assert fake.calls[0]["tencent"] is True
741+
assert silk_path.read_bytes().startswith(b"\x02#!SILK_V3")
742+
743+
744+
@pytest.mark.asyncio
745+
async def test_wav_to_tencent_silk_resamples_stereo(tmp_path, monkeypatch):
746+
"""Stereo input at a supported rate must still be downmixed to mono."""
747+
fake = _FakePysilk()
748+
monkeypatch.setitem(sys.modules, "pysilk", fake)
749+
750+
wav_path = tmp_path / "stereo_48k.wav"
751+
_make_wav(wav_path, 48000, channels=2)
752+
753+
await wav_to_tencent_silk(str(wav_path), str(tmp_path / "out.silk"))
754+
755+
assert len(fake.calls) == 1
756+
# 48000 Hz is supported, so only downmix happens -- rate stays unchanged.
757+
assert fake.calls[0]["sample_rate"] == 48000
758+
759+
760+
@pytest.mark.asyncio
761+
async def test_wav_to_tencent_silk_skips_resample_for_supported_rate(
762+
tmp_path, monkeypatch
763+
):
764+
"""24000 Hz mono must go straight to pysilk without resampling."""
765+
fake = _FakePysilk()
766+
monkeypatch.setitem(sys.modules, "pysilk", fake)
767+
768+
wav_path = tmp_path / "tone_24k.wav"
769+
_make_wav(wav_path, 24000)
770+
771+
await wav_to_tencent_silk(str(wav_path), str(tmp_path / "out.silk"))
772+
773+
assert len(fake.calls) == 1
774+
assert fake.calls[0]["sample_rate"] == 24000

0 commit comments

Comments
 (0)