22import math
33import os
44import struct
5+ import sys
56import wave
67from io import BytesIO
78from 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