snapquest / voice.py
ashish-doing's picture
feat: SnapQuest - photo-to-RPG game using MiniCPM-V 4.6
660c30f
Raw
History Blame Contribute Delete
4.53 kB
"""
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}")