test_voice_tts.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  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_speech_removes_unicode_sequences_and_custom_aliases(self):
  14. text = 'Salut :hap: 😊 ! Ça va ❤️ 👍🏽 👩‍💻 🇫🇷 1️⃣ :desu: <:minou:123> <a:eh:456> ?'
  15. self.assertEqual(list(speech_chunks(text)), ['Salut !', 'Ça va ?'])
  16. def test_speech_preserves_french_numbers_and_times(self):
  17. text = 'Écoute : rendez-vous à 12:30:45, pour 42 € et 20 %.'
  18. self.assertEqual(list(speech_chunks(text)), [text])
  19. async def test_emoji_only_response_is_silent(self):
  20. voice = Mock()
  21. with patch('voice_tts.httpx.AsyncClient') as factory:
  22. factory.return_value = AsyncMock()
  23. await speak(voice, '😊 :hap: 👩‍💻', 'http://tts')
  24. factory.return_value.__aenter__.return_value.stream.assert_not_called()
  25. voice.play.assert_not_called()
  26. def test_chunks_are_bounded_and_remove_discord_markup(self):
  27. chunks = list(speech_chunks('Bonjour ! [Lis ceci](https://example.org) <:hap:123> ' + 'mot ' * 200))
  28. self.assertTrue(all(0 < len(c) <= 240 for c in chunks))
  29. self.assertNotIn('https', ' '.join(chunks))
  30. self.assertNotIn('<:hap', ' '.join(chunks))
  31. self.assertEqual(chunks[0], 'Bonjour !')
  32. def wav(self):
  33. buf = BytesIO()
  34. with wave.open(buf, 'wb') as wav:
  35. wav.setnchannels(1)
  36. wav.setsampwidth(2)
  37. wav.setframerate(24000)
  38. wav.writeframes(b'\0\1' * 2400)
  39. return buf.getvalue()
  40. def test_discord_frames_are_stereo_48khz_20ms(self):
  41. source = WavSource(self.wav())
  42. frames = []
  43. while frame := source.read():
  44. frames.append(frame)
  45. self.assertEqual(len(frames), 5)
  46. self.assertTrue(all(len(frame) == 3840 for frame in frames))
  47. source.cleanup()
  48. async def test_cancel_stops_playback(self):
  49. voice = SimpleNamespace(play=Mock(), stop=Mock())
  50. task = asyncio.create_task(play_wav(voice, self.wav()))
  51. await asyncio.sleep(0)
  52. task.cancel()
  53. with self.assertRaises(asyncio.CancelledError):
  54. await task
  55. voice.stop.assert_called_once()
  56. async def test_batch_statuses_track_success_failure_and_duplicate_phrases(self):
  57. statuses = AsyncMock()
  58. reply = AsyncMock(side_effect=[None, RuntimeError('LLM unavailable')])
  59. sink = STTSink(reply, 'http://unused', on_batch_status=statuses)
  60. try:
  61. sink.pending = [('Alice', 'Bonjour'), ('Alice', 'Bonjour')]
  62. sink.pending_receipts = ['message-1', 'message-2']
  63. sink.pending_event.set()
  64. await asyncio.sleep(0.1)
  65. self.assertEqual(statuses.call_args_list[0].args, (['message-1', 'message-2'], 'processing'))
  66. self.assertEqual(statuses.call_args_list[1].args, (['message-1', 'message-2'], 'done'))
  67. sink.pending = [('Alice', 'Autre phrase')]
  68. sink.pending_receipts = ['message-3']
  69. sink.pending_event.set()
  70. await asyncio.sleep(0.1)
  71. self.assertEqual(statuses.call_args_list[-1].args, (['message-3'], 'error'))
  72. finally:
  73. await sink.close()
  74. async def test_audio_mode_does_not_publish_conversation(self):
  75. tree = ast.parse(Path(__file__).with_name('chatbot.py').read_text(encoding='utf-8'))
  76. function = next(n for n in tree.body if isinstance(n, ast.AsyncFunctionDef)
  77. and n.name == 'handle_voice_command')
  78. http = AsyncMock()
  79. http.__aenter__.return_value.get.return_value = Mock()
  80. sink = SimpleNamespace(close=AsyncMock(), receiver_stopped=Mock(), log_diagnostic=Mock())
  81. factory = Mock(return_value=sink)
  82. voice = SimpleNamespace(sink=sink, listen=Mock(), disconnect=AsyncMock(),
  83. is_connected=lambda: True, is_listening=lambda: True)
  84. channel = SimpleNamespace(connect=AsyncMock(return_value=voice))
  85. voice.channel = channel
  86. message = SimpleNamespace(content='!join audio',
  87. guild=SimpleNamespace(voice_client=None, me=SimpleNamespace(voice=None)),
  88. author=SimpleNamespace(voice=SimpleNamespace(channel=channel)),
  89. channel=SimpleNamespace(send=AsyncMock()))
  90. speak = AsyncMock()
  91. ns = dict(VOICE_ENABLED=True, STT_URL='http://stt/transcribe', TTS_URL='http://tts/v1/audio/speech',
  92. TTS_VOICE='ff_siwis', voice_command_lock=asyncio.Lock(),
  93. httpx=SimpleNamespace(AsyncClient=Mock(return_value=http)),
  94. voice_recv=SimpleNamespace(VoiceRecvClient=object), STTSink=factory,
  95. call_openai_api=AsyncMock(return_value=SimpleNamespace(choices=[
  96. SimpleNamespace(message=SimpleNamespace(content='Bonjour Alice.'))])),
  97. filter_message=lambda x: x, speak=speak, discord=discord, logger=Mock())
  98. exec(compile(ast.Module(body=[function], type_ignores=[]), '<commands>', 'exec'), ns)
  99. await ns['handle_voice_command'](message)
  100. self.assertIsNone(factory.call_args.kwargs['on_transcription'])
  101. message.channel.send.reset_mock()
  102. await factory.call_args.args[0]([(SimpleNamespace(display_name='Alice'), 'Salut')])
  103. speak.assert_awaited_once_with(voice, 'Bonjour Alice.', ns['TTS_URL'], 'ff_siwis')
  104. message.channel.send.assert_not_awaited()