test_voice_stt.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. """Tests hors Discord : python -m unittest test_voice_stt."""
  2. import asyncio
  3. import ast
  4. import time
  5. import unittest
  6. import wave
  7. from io import BytesIO
  8. from pathlib import Path
  9. from types import SimpleNamespace
  10. from unittest.mock import AsyncMock, Mock, patch
  11. import httpx
  12. import discord
  13. from voice_stt import STTSink
  14. class VoiceTests(unittest.IsolatedAsyncioTestCase):
  15. async def asyncSetUp(self):
  16. self.reply = AsyncMock()
  17. self.sink = STTSink(self.reply, 'http://stt/transcribe')
  18. self.user = SimpleNamespace(id=1, bot=False)
  19. self.pcm = b'\x00\x10' * (48000 * 2 // 2) # 0,5 s stéréo
  20. async def asyncTearDown(self):
  21. await self.sink.close()
  22. async def test_silence_and_separate_speakers(self):
  23. self.sink.worker_task.cancel()
  24. with self.sink.lock:
  25. self.sink.add_pcm(self.user, bytes(len(self.pcm)), time.monotonic())
  26. self.assertFalse(self.sink.buffers)
  27. self.sink.add_pcm(self.user, self.pcm, time.monotonic() - 2)
  28. self.sink.add_pcm(SimpleNamespace(id=2), self.pcm, time.monotonic())
  29. await asyncio.sleep(0.15)
  30. user, pcm = self.sink.queue.get_nowait()
  31. self.sink.queue.task_done()
  32. self.assertEqual(user.id, 1)
  33. self.assertEqual(pcm, self.pcm)
  34. self.assertIn(2, self.sink.buffers)
  35. async def test_audio_is_bounded_and_short_noise_ignored(self):
  36. self.sink.worker_task.cancel()
  37. with self.sink.lock:
  38. self.sink.add_pcm(self.user, self.pcm * 50, time.monotonic())
  39. self.assertEqual(len(self.sink.buffers[1][1]), 48000 * 4 * 20)
  40. self.sink.add_pcm(SimpleNamespace(id=2), self.pcm[:1000], time.monotonic() - 2)
  41. await asyncio.sleep(0.15)
  42. self.assertEqual(self.sink.queue.qsize(), 1)
  43. self.assertNotIn(2, self.sink.buffers)
  44. async def test_dave_decrypted_before_opus(self):
  45. session = SimpleNamespace(ready=True, decrypt=Mock(return_value=b'opus'))
  46. self.sink._voice_client = SimpleNamespace(_connection=SimpleNamespace(
  47. dave_protocol_version=1, dave_session=session))
  48. data = SimpleNamespace(opus=b'encrypted', packet=SimpleNamespace(is_silence=lambda: False))
  49. decoder = Mock()
  50. decoder.decode.return_value = self.pcm
  51. with patch('voice_stt.discord.opus.Decoder', return_value=decoder):
  52. self.sink.write(self.user, data)
  53. session.decrypt.assert_called_once()
  54. decoder.decode.assert_called_once_with(b'opus', fec=False)
  55. self.assertEqual(bytes(self.sink.buffers[1][1]), self.pcm)
  56. async def test_http_recovery_and_wav_format(self):
  57. await self.sink.close()
  58. post = AsyncMock(side_effect=[
  59. httpx.ConnectError('offline'),
  60. httpx.Response(200, json={'text': 'Bonjour'}, request=httpx.Request('POST', 'http://stt')),
  61. ])
  62. http = AsyncMock()
  63. http.__aenter__.return_value.post = post
  64. with patch('voice_stt.httpx.AsyncClient', return_value=http):
  65. self.sink = STTSink(self.reply, 'http://stt/transcribe')
  66. self.sink.queue.put_nowait((self.user, self.pcm))
  67. self.sink.queue.put_nowait((self.user, self.pcm))
  68. await asyncio.wait_for(self.sink.queue.join(), 2)
  69. self.reply.assert_awaited_once_with(self.user, 'Bonjour')
  70. payload = post.call_args.kwargs['files']['file'][1]
  71. with wave.open(BytesIO(payload)) as wav:
  72. self.assertEqual((wav.getnchannels(), wav.getsampwidth(), wav.getframerate()), (2, 2, 48000))
  73. self.assertEqual(wav.readframes(wav.getnframes()), self.pcm)
  74. async def test_close_discards_pending_audio(self):
  75. self.sink.queue.put_nowait((self.user, self.pcm))
  76. await self.sink.close()
  77. self.assertTrue(self.sink.closed)
  78. self.assertTrue(self.sink.queue.empty())
  79. self.assertTrue(self.sink.flush_task.done())
  80. self.assertTrue(self.sink.worker_task.done())
  81. self.reply.assert_not_awaited()
  82. async def test_diagnostics_distinguish_ignored_audio_and_dave_failure(self):
  83. packet = SimpleNamespace(is_silence=lambda: False)
  84. data = SimpleNamespace(opus=b'PRIVATE_AUDIO', packet=packet)
  85. session = SimpleNamespace(ready=False, decrypt=Mock(side_effect=ValueError('PRIVATE_KEY')))
  86. self.sink._voice_client = SimpleNamespace(_connection=SimpleNamespace(
  87. dave_protocol_version=1, dave_session=session))
  88. self.sink.on_rtcp_packet(None, None)
  89. self.sink.write(None, data)
  90. self.sink.write(self.user, data)
  91. session.ready = True
  92. with self.assertLogs('voice_stt', level='INFO') as logs:
  93. self.sink.write(self.user, data)
  94. self.sink.log_diagnostic()
  95. self.assertEqual(self.sink.stats['audio'], 3)
  96. for reason in ('rtcp', 'unknown_user', 'dave_not_ready', 'DAVE_errors'):
  97. self.assertEqual(self.sink.stats[reason], 1)
  98. self.assertEqual(self.sink.stats['pcm'], 0)
  99. self.assertNotIn('PRIVATE_AUDIO', str(logs.output))
  100. self.assertNotIn('PRIVATE_KEY', str(logs.output))
  101. async def test_diagnostics_without_any_audio_and_final_report(self):
  102. with self.assertLogs('voice_stt', level='INFO') as logs:
  103. self.sink.log_diagnostic()
  104. await self.sink.close()
  105. await self.sink.close()
  106. self.assertIn("'audio': 0", str(logs.output))
  107. self.assertEqual(sum('arrêt' in line for line in logs.output), 1)
  108. class CommandTests(unittest.IsolatedAsyncioTestCase):
  109. async def test_join_replies_in_command_channel_and_quit_closes_sink(self):
  110. # Charger uniquement la fonction : aucun token ni connexion Discord.
  111. tree = ast.parse(Path(__file__).with_name('chatbot.py').read_text(encoding='utf-8'))
  112. function = next(node for node in tree.body if isinstance(node, ast.AsyncFunctionDef)
  113. and node.name == 'handle_voice_command')
  114. http = AsyncMock()
  115. http.__aenter__.return_value.get.return_value = Mock()
  116. sink = SimpleNamespace(close=AsyncMock(), receiver_stopped=Mock(), log_diagnostic=Mock())
  117. make_sink = Mock(return_value=sink)
  118. voice = SimpleNamespace(sink=sink, listen=Mock(), disconnect=AsyncMock(),
  119. is_connected=lambda: True, is_listening=lambda: True)
  120. channel = SimpleNamespace(connect=AsyncMock(return_value=voice))
  121. voice.channel = channel
  122. message = SimpleNamespace(
  123. content='!join', guild=SimpleNamespace(voice_client=None, me=SimpleNamespace(voice=None)),
  124. author=SimpleNamespace(voice=SimpleNamespace(channel=channel)),
  125. channel=SimpleNamespace(send=AsyncMock(), typing=AsyncMock()),
  126. )
  127. message.channel.typing = Mock(return_value=AsyncMock())
  128. call_api = AsyncMock(return_value=SimpleNamespace(
  129. choices=[SimpleNamespace(message=SimpleNamespace(content='Réponse'))]))
  130. namespace = dict(
  131. VOICE_ENABLED=True, STT_URL='http://stt/transcribe',
  132. voice_command_lock=asyncio.Lock(), httpx=SimpleNamespace(AsyncClient=Mock(return_value=http)),
  133. voice_recv=SimpleNamespace(VoiceRecvClient=object), STTSink=make_sink,
  134. call_openai_api=call_api, transform_emote=lambda text, output: text,
  135. filter_message=lambda text: text, split_message=lambda text: [text],
  136. discord=discord, logger=Mock(),
  137. )
  138. exec(compile(ast.Module(body=[function], type_ignores=[]), '<commands>', 'exec'), namespace)
  139. await namespace['handle_voice_command'](message)
  140. voice.listen.assert_called_once_with(sink, after=sink.receiver_stopped)
  141. callback = make_sink.call_args.args[0]
  142. await callback(SimpleNamespace(display_name='Alice'), 'Bonjour')
  143. call_api.assert_awaited_once_with('Bonjour', 'Alice')
  144. self.assertEqual(message.channel.send.call_args.args[0], 'Réponse')
  145. message.content = '!quit'
  146. message.guild.voice_client = voice
  147. await namespace['handle_voice_command'](message)
  148. sink.close.assert_awaited_once()
  149. voice.disconnect.assert_awaited_once_with(force=True)
  150. if __name__ == '__main__':
  151. unittest.main()