sl-jetson 90c8b427fc
Some checks failed
social-bot integration tests / Lint (flake8 + pep257) (push) Failing after 2s
social-bot integration tests / Core integration tests (mock sensors, no GPU) (push) Has been skipped
social-bot integration tests / Lint (flake8 + pep257) (pull_request) Failing after 10s
social-bot integration tests / Core integration tests (mock sensors, no GPU) (pull_request) Has been skipped
social-bot integration tests / Latency profiling (GPU, Orin) (push) Has been cancelled
social-bot integration tests / Latency profiling (GPU, Orin) (pull_request) Has been cancelled
feat(social): multi-language support — Whisper LID + per-lang Piper TTS (Issue #167)
- Add SpeechTranscript.language (BCP-47), ConversationResponse.language fields
- speech_pipeline_node: whisper_language param (""=auto-detect via Whisper LID);
  detected language published in every transcript
- conversation_node: track per-speaker language; inject "[Please respond in X.]"
  hint for non-English speakers; propagate language to ConversationResponse.
  _LANG_NAMES: 24 BCP-47 codes -> English names. Also adds Issue #161 emotion
  context plumbing (co-located in same branch for clean merge)
- tts_node: voice_map_json param (JSON BCP-47->ONNX path); lazy voice loading
  per language; playback queue now carries (text, lang) tuples for voice routing
- speech_params.yaml, tts_params.yaml: new language params with docs
- 47/47 tests pass (test_multilang.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 10:57:34 -05:00

123 lines
5.6 KiB
Python

"""test_multilang.py -- Unit tests for Issue #167 multi-language support."""
from __future__ import annotations
import json, os
from typing import Any, Dict, Optional
import pytest
def _pkg_root():
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _read_src(rel_path):
with open(os.path.join(_pkg_root(), rel_path)) as f:
return f.read()
def _extract_lang_names():
import ast
src = _read_src("saltybot_social/conversation_node.py")
start = src.index("_LANG_NAMES: Dict[str, str] = {")
end = src.index("\n}", start) + 2
return ast.literal_eval(src[start:end].split("=",1)[1].strip())
class TestLangNames:
@pytest.fixture(scope="class")
def ln(self): return _extract_lang_names()
def test_english(self, ln): assert ln["en"] == "English"
def test_french(self, ln): assert ln["fr"] == "French"
def test_spanish(self, ln): assert ln["es"] == "Spanish"
def test_german(self, ln): assert ln["de"] == "German"
def test_japanese(self, ln):assert ln["ja"] == "Japanese"
def test_chinese(self, ln): assert ln["zh"] == "Chinese"
def test_arabic(self, ln): assert ln["ar"] == "Arabic"
def test_at_least_15(self, ln): assert len(ln) >= 15
def test_lowercase_keys(self, ln):
for k in ln: assert k == k.lower() and 2 <= len(k) <= 3
def test_nonempty_values(self, ln):
for k, v in ln.items(): assert v
class TestLanguageHint:
def _h(self, sl, sid, ln):
lang = sl.get(sid, "en")
return f"[Please respond in {ln.get(lang, lang)}.]" if lang and lang != "en" else ""
@pytest.fixture(scope="class")
def ln(self): return _extract_lang_names()
def test_english_no_hint(self, ln): assert self._h({"p": "en"}, "p", ln) == ""
def test_unknown_no_hint(self, ln): assert self._h({}, "p", ln) == ""
def test_french(self, ln): assert self._h({"p":"fr"},"p",ln) == "[Please respond in French.]"
def test_spanish(self, ln): assert self._h({"p":"es"},"p",ln) == "[Please respond in Spanish.]"
def test_unknown_code(self, ln): assert "xx" in self._h({"p":"xx"},"p",ln)
def test_brackets(self, ln):
h = self._h({"p":"de"},"p",ln)
assert h.startswith("[") and h.endswith("]")
class TestVoiceMap:
def _parse(self, jstr, dl, dp):
try: extra = json.loads(jstr) if jstr.strip() not in ("{}","") else {}
except: extra = {}
r = {dl: dp}; r.update(extra); return r
def test_empty(self): assert self._parse("{}","en","/e") == {"en":"/e"}
def test_extra(self):
vm = self._parse('{"fr":"/f"}', "en", "/e")
assert vm["fr"] == "/f"
def test_invalid(self): assert self._parse("BAD","en","/e") == {"en":"/e"}
def test_multi(self):
assert len(self._parse(json.dumps({"fr":"/f","es":"/s"}),"en","/e")) == 3
class TestVoiceSelect:
def _s(self, voices, lang, default):
return voices.get(lang) or voices.get(default)
def test_exact(self): assert self._s({"en":"E","fr":"F"},"fr","en") == "F"
def test_fallback(self): assert self._s({"en":"E"},"fr","en") == "E"
def test_none(self): assert self._s({},"fr","en") is None
class TestSttFields:
@pytest.fixture(scope="class")
def src(self): return _read_src("saltybot_social/speech_pipeline_node.py")
def test_param(self, src): assert "whisper_language" in src
def test_detected_lang(self, src): assert "detected_lang" in src
def test_msg_language(self, src): assert "msg.language = language" in src
def test_auto_detect(self, src): assert "language=self._whisper_language" in src
class TestConvFields:
@pytest.fixture(scope="class")
def src(self): return _read_src("saltybot_social/conversation_node.py")
def test_speaker_lang(self, src): assert "_speaker_lang" in src
def test_lang_hint_method(self, src): assert "_language_hint" in src
def test_msg_language(self, src): assert "msg.language = language" in src
def test_lang_names(self, src): assert "_LANG_NAMES" in src
def test_please_respond(self, src): assert "Please respond in" in src
def test_emotion_coexists(self, src): assert "_emotion_hint" in src
class TestTtsFields:
@pytest.fixture(scope="class")
def src(self): return _read_src("saltybot_social/tts_node.py")
def test_voice_map_json(self, src): assert "voice_map_json" in src
def test_default_lang(self, src): assert "default_language" in src
def test_voices_dict(self, src): assert "_voices" in src
def test_get_voice(self, src): assert "_get_voice" in src
def test_load_voice_for_lang(self, src): assert "_load_voice_for_lang" in src
def test_queue_tuple(self, src): assert "(text.strip(), lang)" in src
def test_synthesize_voice_arg(self, src): assert "_synthesize(text, voice)" in src
class TestMsgDefs:
@pytest.fixture(scope="class")
def tr(self): return _read_src("../saltybot_social_msgs/msg/SpeechTranscript.msg")
@pytest.fixture(scope="class")
def re(self): return _read_src("../saltybot_social_msgs/msg/ConversationResponse.msg")
def test_transcript_lang(self, tr): assert "string language" in tr
def test_transcript_bcp47(self, tr): assert "BCP-47" in tr
def test_response_lang(self, re): assert "string language" in re
class TestEdgeCases:
def test_empty_lang_no_hint(self):
lang = "" or "en"; assert lang == "en"
def test_lang_flows(self):
d: Dict[str,str] = {}; d["p1"] = "fr"
assert d.get("p1","en") == "fr"
def test_multi_speakers(self):
d = {"p1":"fr","p2":"es"}
assert d["p1"] == "fr" and d["p2"] == "es"
def test_voice_map_code_in_tts(self):
src = _read_src("saltybot_social/tts_node.py")
assert "voice_map_json" in src and "json.loads" in src