test_voice_tts.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import asyncio
  2. import ast
  3. import unittest
  4. import wave
  5. from io import BytesIO
  6. from pathlib import Path
  7. from types import SimpleNamespace
  8. from unittest.mock import AsyncMock, Mock, patch
  9. import discord
  10. from voice_tts import WavSource, speech_chunks, play_wav, speak
  11. from voice_stt import STTSink
  12. class AudioTests(unittest.IsolatedAsyncioTestCase):
  13. def test_short_first_chunk_preserves_all_words(self):
  14. text = 'Je peux te répondre rapidement, puis poursuivre avec les détails pendant la lecture du début.'
  15. chunks = list(speech_chunks(text, limit=80, first_limit=48))
  16. self.assertEqual(chunks[0], 'Je peux te répondre rapidement,')
  17. self.assertEqual(' '.join(chunks), text)
  18. self.assertLessEqual(len(chunks[0]), 48)
  19. self.assertTrue(all(len(chunk) <= 80 for chunk in chunks))
  20. async def test_next_synthesis_overlaps_playback_and_is_cancelled(self):
  21. second_started = asyncio.Event()
  22. second_cancelled = asyncio.Event()
  23. first_played = asyncio.Event()
  24. requests = []
  25. class Response:
  26. async def __aenter__(self):
  27. return self
  28. async def __aexit__(self, *args):
  29. pass
  30. def raise_for_status(self):
  31. pass
  32. async def aiter_bytes(self):
  33. if len(requests) == 2:
  34. second_started.set()
  35. try:
  36. await asyncio.Event().wait()
  37. finally:
  38. second_cancelled.set()
  39. yield b'wav'
  40. def stream(*args, **kwargs):
  41. requests.append(kwargs['json']['input'])
  42. return Response()
  43. async def play(*args):
  44. first_played.set()
  45. await second_started.wait()
  46. await asyncio.Event().wait()
  47. http = AsyncMock()
  48. http.__aenter__.return_value.stream = Mock(side_effect=stream)
  49. with patch('voice_tts.httpx.AsyncClient', return_value=http), patch('voice_tts.play_wav', side_effect=play):
  50. task = asyncio.create_task(speak(Mock(), 'Bonjour. Voici la suite.', 'http://tts'))
  51. try:
  52. await asyncio.wait_for(second_started.wait(), 1)
  53. self.assertTrue(first_played.is_set())
  54. self.assertEqual(requests, ['Bonjour.', 'Voici la suite.'])
  55. finally:
  56. task.cancel()
  57. with self.assertRaises(asyncio.CancelledError):
  58. await task
  59. self.assertTrue(second_cancelled.is_set())
  60. def test_speech_removes_unicode_sequences_and_custom_aliases(self):
  61. text = 'Salut :hap: 😊 ! Ça va ❤️ 👍🏽 👩‍💻 🇫🇷 1️⃣ :desu: <:minou:123> <a:eh:456> ?'
  62. self.assertEqual(list(speech_chunks(text)), ['Salut !', 'Ça va ?'])
  63. def test_speech_preserves_french_numbers_and_times(self):
  64. text = 'Écoute : rendez-vous à 12:30:45, pour 42 € et 20 %.'
  65. self.assertEqual(list(speech_chunks(text)), [text])
  66. async def test_emoji_only_response_is_silent(self):
  67. voice = Mock()
  68. with patch('voice_tts.httpx.AsyncClient') as factory:
  69. factory.return_value = AsyncMock()
  70. await speak(voice, '😊 :hap: 👩‍💻', 'http://tts')
  71. factory.return_value.__aenter__.return_value.stream.assert_not_called()
  72. voice.play.assert_not_called()
  73. def test_chunks_are_bounded_and_remove_discord_markup(self):
  74. chunks = list(speech_chunks('Bonjour ! [Lis ceci](https://example.org) <:hap:123> ' + 'mot ' * 200))
  75. self.assertTrue(all(0 < len(c) <= 240 for c in chunks))
  76. self.assertNotIn('https', ' '.join(chunks))
  77. self.assertNotIn('<:hap', ' '.join(chunks))
  78. self.assertEqual(chunks[0], 'Bonjour !')
  79. def wav(self):
  80. buf = BytesIO()
  81. with wave.open(buf, 'wb') as wav:
  82. wav.setnchannels(1)
  83. wav.setsampwidth(2)
  84. wav.setframerate(24000)
  85. wav.writeframes(b'\0\1' * 2400)
  86. return buf.getvalue()
  87. def test_discord_frames_are_stereo_48khz_20ms(self):
  88. source = WavSource(self.wav())
  89. frames = []
  90. while frame := source.read():
  91. frames.append(frame)
  92. self.assertEqual(len(frames), 5)
  93. self.assertTrue(all(len(frame) == 3840 for frame in frames))
  94. source.cleanup()
  95. async def test_cancel_stops_playback(self):
  96. voice = SimpleNamespace(play=Mock(), stop=Mock())
  97. task = asyncio.create_task(play_wav(voice, self.wav()))
  98. await asyncio.sleep(0)
  99. task.cancel()
  100. with self.assertRaises(asyncio.CancelledError):
  101. await task
  102. voice.stop.assert_called_once()
  103. async def test_batch_statuses_track_success_failure_and_duplicate_phrases(self):
  104. statuses = AsyncMock()
  105. reply = AsyncMock(side_effect=[None, RuntimeError('LLM unavailable')])
  106. sink = STTSink(reply, 'http://unused', on_batch_status=statuses)
  107. try:
  108. sink.pending = [('Alice', 'Bonjour'), ('Alice', 'Bonjour')]
  109. sink.pending_receipts = ['message-1', 'message-2']
  110. sink.pending_event.set()
  111. await asyncio.sleep(0.1)
  112. self.assertEqual(statuses.call_args_list[0].args, (['message-1', 'message-2'], 'processing'))
  113. self.assertEqual(statuses.call_args_list[1].args, (['message-1', 'message-2'], 'done'))
  114. sink.pending = [('Alice', 'Autre phrase')]
  115. sink.pending_receipts = ['message-3']
  116. sink.pending_event.set()
  117. await asyncio.sleep(0.1)
  118. self.assertEqual(statuses.call_args_list[-1].args, (['message-3'], 'error'))
  119. finally:
  120. await sink.close()
  121. async def test_audio_mode_does_not_publish_conversation(self):
  122. tree = ast.parse(Path(__file__).with_name('chatbot.py').read_text(encoding='utf-8'))
  123. function = next(n for n in tree.body if isinstance(n, ast.AsyncFunctionDef)
  124. and n.name == 'handle_voice_command')
  125. http = AsyncMock()
  126. http.__aenter__.return_value.get.return_value = Mock()
  127. sink = SimpleNamespace(close=AsyncMock(), receiver_stopped=Mock(), log_diagnostic=Mock())
  128. factory = Mock(return_value=sink)
  129. voice = SimpleNamespace(sink=sink, listen=Mock(), disconnect=AsyncMock(),
  130. is_connected=lambda: True, is_listening=lambda: True)
  131. channel = SimpleNamespace(connect=AsyncMock(return_value=voice))
  132. voice.channel = channel
  133. message = SimpleNamespace(content='!join audio',
  134. guild=SimpleNamespace(voice_client=None, me=SimpleNamespace(voice=None)),
  135. author=SimpleNamespace(voice=SimpleNamespace(channel=channel)),
  136. channel=SimpleNamespace(send=AsyncMock()))
  137. speak = AsyncMock()
  138. ns = dict(VOICE_ENABLED=True, STT_URL='http://stt/transcribe', TTS_URL='http://tts/v1/audio/speech',
  139. TTS_VOICE='ff_siwis', voice_command_lock=asyncio.Lock(),
  140. httpx=SimpleNamespace(AsyncClient=Mock(return_value=http)),
  141. voice_recv=SimpleNamespace(VoiceRecvClient=object), STTSink=factory,
  142. call_openai_api=AsyncMock(return_value=SimpleNamespace(choices=[
  143. SimpleNamespace(message=SimpleNamespace(content='Bonjour Alice.'))])),
  144. filter_message=lambda x: x, speak=speak, discord=discord, logger=Mock())
  145. exec(compile(ast.Module(body=[function], type_ignores=[]), '<commands>', 'exec'), ns)
  146. await ns['handle_voice_command'](message)
  147. self.assertIsNone(factory.call_args.kwargs['on_transcription'])
  148. message.channel.send.reset_mock()
  149. await factory.call_args.args[0]([(SimpleNamespace(display_name='Alice'), 'Salut')])
  150. speak.assert_awaited_once_with(voice, 'Bonjour Alice.', ns['TTS_URL'], 'ff_siwis')
  151. message.channel.send.assert_not_awaited()