""" voice.py — ChronoQuest Audio I/O STT : microphone .wav/.mp3 file -> faster-whisper (small) -> text TTS : text -> edge-tts (en-GB-RyanNeural) -> .mp3 file path """ import asyncio import os import re import logging from typing import Optional logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # STT # --------------------------------------------------------------------------- def transcribe_audio(audio_path: str) -> str: if not audio_path or not os.path.isfile(audio_path): logger.warning("transcribe_audio: invalid or missing path: %r", audio_path) return "" try: from faster_whisper import WhisperModel model = WhisperModel("small", device="cpu", compute_type="int8") segments, _info = model.transcribe(audio_path, beam_size=5) text = " ".join(seg.text.strip() for seg in segments).strip() return text except ImportError: logger.debug("faster-whisper not installed, trying openai-whisper...") except Exception as exc: logger.error("faster-whisper failed: %s", exc) try: import whisper model = whisper.load_model("small") result = model.transcribe(audio_path) return result.get("text", "").strip() except ImportError: logger.error("No Whisper library found. pip install faster-whisper") except Exception as exc: logger.error("openai-whisper failed: %s", exc) return "" # --------------------------------------------------------------------------- # Text cleaning # --------------------------------------------------------------------------- _ASCII_ART_RE = re.compile( r"[╔╗╚╝║═╠╣╦╩╬┌┐└┘│─├┤┬┴┼▓▒░█▄▀■□▪▫◆◇●○►◄★☆✦✧✨⚔️🗡️🏰⚡🌿💀]{2,}" ) _MARKDOWN_RE = re.compile(r"[*_`#>|\\]") _MULTI_SPACE_RE = re.compile(r"\s{2,}") def clean_for_speech(dm_response: dict) -> str: scene = str(dm_response.get("scene", "")).strip() story = str(dm_response.get("story", "")).strip() combined = f"{scene} {story}".strip() if scene else story combined = _ASCII_ART_RE.sub(" ", combined) combined = _MARKDOWN_RE.sub("", combined) combined = _MULTI_SPACE_RE.sub(" ", combined).strip() words = combined.split() if len(words) > 100: combined = " ".join(words[:100]) + "..." return combined # --------------------------------------------------------------------------- # TTS # --------------------------------------------------------------------------- async def speak_async(text: str, output_path: str = None) -> str: if output_path is None: import tempfile, os output_path = os.path.join(tempfile.gettempdir(), "dm_voice.mp3") try: import edge_tts except ImportError as exc: raise RuntimeError("edge-tts not installed. pip install edge-tts") from exc if not text or not text.strip(): return output_path communicate = edge_tts.Communicate(text=text, voice="en-GB-RyanNeural") await communicate.save(output_path) return output_path def speak(text: str, output_path: str = None) -> str: if output_path is None: import tempfile, os output_path = os.path.join(tempfile.gettempdir(), "dm_voice.mp3") try: loop = asyncio.get_event_loop() if loop.is_running(): import concurrent.futures with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: future = pool.submit(asyncio.run, speak_async(text, output_path)) return future.result() else: return loop.run_until_complete(speak_async(text, output_path)) except RuntimeError: return asyncio.run(speak_async(text, output_path)) # --------------------------------------------------------------------------- # Self-test # --------------------------------------------------------------------------- if __name__ == "__main__": import sys logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s") out = speak("Welcome to ChronoQuest. The dungeon awaits.") if os.path.isfile(out): size_kb = os.path.getsize(out) / 1024 print(f"TTS OK -> {out} ({size_kb:.1f} KB)") else: print(f"TTS FAILED") sys.exit(1) fake = {"scene": "A dark corridor.", "story": "You move forward.", "choices": []} print(f"clean_for_speech: {clean_for_speech(fake)!r}")