Commit ·
879a21c
1
Parent(s): b0558d5
Add brick tests and CLI runners
Browse files- scripts/bootstrap.py +10 -0
- scripts/test_asl_brick.py +25 -0
- scripts/test_full_pipeline.py +30 -0
- scripts/test_llm_brick.py +25 -0
- scripts/test_tts_brick.py +23 -0
- tests/test_asl_pipeline.py +38 -0
- tests/test_llm_parsing.py +43 -0
- tests/test_tts.py +37 -0
scripts/bootstrap.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
+
if str(REPO_ROOT) not in sys.path:
|
| 9 |
+
sys.path.insert(0, str(REPO_ROOT))
|
| 10 |
+
|
scripts/test_asl_brick.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
import bootstrap # noqa: F401
|
| 7 |
+
|
| 8 |
+
from signspeak.pipeline import run_asl_video
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def main() -> None:
|
| 12 |
+
parser = argparse.ArgumentParser(description="Run only the ASL video brick.")
|
| 13 |
+
parser.add_argument("video", nargs="?", default=None, help="Video path. Creates a demo clip if omitted.")
|
| 14 |
+
args = parser.parse_args()
|
| 15 |
+
|
| 16 |
+
intent_json, result, summary = run_asl_video(args.video)
|
| 17 |
+
print(summary)
|
| 18 |
+
print("\nIntent JSON:")
|
| 19 |
+
print(intent_json)
|
| 20 |
+
print("\nFull ASL output:")
|
| 21 |
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
if __name__ == "__main__":
|
| 25 |
+
main()
|
scripts/test_full_pipeline.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
|
| 5 |
+
import bootstrap # noqa: F401
|
| 6 |
+
|
| 7 |
+
from signspeak.llm import generate_subtitle_and_instruction
|
| 8 |
+
from signspeak.pipeline import run_asl_video
|
| 9 |
+
from signspeak.tts import generate_tts
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def main() -> None:
|
| 13 |
+
parser = argparse.ArgumentParser(description="Run ASL -> llama.cpp -> Qwen3-TTS.")
|
| 14 |
+
parser.add_argument("video", nargs="?", default=None, help="Video path. Creates a demo clip if omitted.")
|
| 15 |
+
parser.add_argument("--language", default="English")
|
| 16 |
+
parser.add_argument("--speaker", default="Ryan")
|
| 17 |
+
args = parser.parse_args()
|
| 18 |
+
|
| 19 |
+
intent_json, _, summary = run_asl_video(args.video)
|
| 20 |
+
subtitle, instruction, _ = generate_subtitle_and_instruction(intent_json)
|
| 21 |
+
audio_path = generate_tts(subtitle, args.language, args.speaker, instruction)
|
| 22 |
+
|
| 23 |
+
print(summary)
|
| 24 |
+
print(f"Subtitle: {subtitle}")
|
| 25 |
+
print(f"Voice instruction: {instruction}")
|
| 26 |
+
print(f"Audio: {audio_path}")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
if __name__ == "__main__":
|
| 30 |
+
main()
|
scripts/test_llm_brick.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import bootstrap # noqa: F401
|
| 7 |
+
|
| 8 |
+
from signspeak.llm import generate_subtitle_and_instruction
|
| 9 |
+
from signspeak.pipeline import DEFAULT_INTENT, json_text
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def main() -> None:
|
| 13 |
+
parser = argparse.ArgumentParser(description="Run only the llama.cpp intent-to-text brick.")
|
| 14 |
+
parser.add_argument("--intent", help="Path to an intent JSON file. Uses default mock intent if omitted.")
|
| 15 |
+
args = parser.parse_args()
|
| 16 |
+
|
| 17 |
+
intent_json = Path(args.intent).read_text(encoding="utf-8") if args.intent else json_text(DEFAULT_INTENT)
|
| 18 |
+
subtitle, instruction, raw = generate_subtitle_and_instruction(intent_json)
|
| 19 |
+
print(f"Subtitle: {subtitle}")
|
| 20 |
+
print(f"Voice instruction: {instruction}")
|
| 21 |
+
print(f"Structured output: {raw}")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
if __name__ == "__main__":
|
| 25 |
+
main()
|
scripts/test_tts_brick.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
|
| 5 |
+
import bootstrap # noqa: F401
|
| 6 |
+
|
| 7 |
+
from signspeak.tts import generate_tts
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def main() -> None:
|
| 11 |
+
parser = argparse.ArgumentParser(description="Run only the Qwen3-TTS brick.")
|
| 12 |
+
parser.add_argument("--text", default="I am happy to see you.")
|
| 13 |
+
parser.add_argument("--instruction", default="Speak warmly, joyfully, and clearly.")
|
| 14 |
+
parser.add_argument("--language", default="English")
|
| 15 |
+
parser.add_argument("--speaker", default="Ryan")
|
| 16 |
+
args = parser.parse_args()
|
| 17 |
+
|
| 18 |
+
output_path = generate_tts(args.text, args.language, args.speaker, args.instruction)
|
| 19 |
+
print(output_path)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
if __name__ == "__main__":
|
| 23 |
+
main()
|
tests/test_asl_pipeline.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from signspeak.asl.pipeline import build_intent_input
|
| 2 |
+
from signspeak.pipeline import summarize_asl_result
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def test_build_intent_input_matches_llm_schema():
|
| 6 |
+
asl = {
|
| 7 |
+
"status": "ok",
|
| 8 |
+
"gloss_sequence": ["I", "HAPPY", "SEE", "YOU"],
|
| 9 |
+
"confidence": 0.91,
|
| 10 |
+
"frames_used": 30,
|
| 11 |
+
}
|
| 12 |
+
emotion = {
|
| 13 |
+
"status": "ok",
|
| 14 |
+
"dominant_emotion": "happy",
|
| 15 |
+
"intensity": 0.82,
|
| 16 |
+
"emotion_scores": {"happy": 0.82, "neutral": 0.18},
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
intent = build_intent_input(asl, emotion)
|
| 20 |
+
|
| 21 |
+
assert intent["detected_glosses"] == ["I", "HAPPY", "SEE", "YOU"]
|
| 22 |
+
assert intent["detected_facial_expression"] == "happy"
|
| 23 |
+
assert intent["emotion_profile"]["confidence"] == 0.82
|
| 24 |
+
assert intent["sign_confidence"] == 0.91
|
| 25 |
+
assert intent["diagnostics"]["asl_status"] == "ok"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_summarize_asl_result_is_stable_for_missing_fields():
|
| 29 |
+
summary = summarize_asl_result(
|
| 30 |
+
{
|
| 31 |
+
"asl": {"status": "model_missing"},
|
| 32 |
+
"emotion": {"dominant_emotion": "unknown", "intensity": 0.0},
|
| 33 |
+
}
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
assert "ASL status: model_missing" in summary
|
| 37 |
+
assert "Emotion: unknown (0.00)" in summary
|
| 38 |
+
|
tests/test_llm_parsing.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from signspeak.llm import extract_json_object, normalize_llm_output, safe_json_loads
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_extract_json_object_from_markdown_fence():
|
| 5 |
+
raw = """```json
|
| 6 |
+
{"subtitle": "I'm happy to see you!", "voice_instruction": "Speak warmly."}
|
| 7 |
+
```"""
|
| 8 |
+
|
| 9 |
+
parsed = extract_json_object(raw)
|
| 10 |
+
|
| 11 |
+
assert parsed["subtitle"] == "I'm happy to see you!"
|
| 12 |
+
assert parsed["voice_instruction"] == "Speak warmly."
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_extract_json_object_from_text_wrapper():
|
| 16 |
+
raw = 'Result: {"subtitle": "Hello.", "voice_instruction": "Speak clearly."} Done.'
|
| 17 |
+
|
| 18 |
+
parsed = extract_json_object(raw)
|
| 19 |
+
|
| 20 |
+
assert parsed == {
|
| 21 |
+
"subtitle": "Hello.",
|
| 22 |
+
"voice_instruction": "Speak clearly.",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_normalize_llm_output_blocks_json_subtitle():
|
| 27 |
+
normalized = normalize_llm_output(
|
| 28 |
+
{
|
| 29 |
+
"subtitle": '{"subtitle": "Bad nested payload"}',
|
| 30 |
+
"voice_instruction": "Speak warmly.",
|
| 31 |
+
}
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
assert normalized["subtitle"] == "I am happy to see you."
|
| 35 |
+
assert normalized["voice_instruction"] == "Speak warmly."
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_safe_json_loads_falls_back_to_raw_text():
|
| 39 |
+
parsed = safe_json_loads("not json")
|
| 40 |
+
|
| 41 |
+
assert parsed["raw_input"] == "not json"
|
| 42 |
+
assert "warning" in parsed
|
| 43 |
+
|
tests/test_tts.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from types import SimpleNamespace
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
import signspeak.tts as tts_module
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class FakeTTS:
|
| 10 |
+
def generate_custom_voice(self, text, language, speaker, instruct):
|
| 11 |
+
assert text == "Hello."
|
| 12 |
+
assert language == "English"
|
| 13 |
+
assert speaker == "Ryan"
|
| 14 |
+
assert instruct == "Speak clearly."
|
| 15 |
+
return [[0.0, 0.1, 0.0]], 24000
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_generate_tts_uses_model_and_writes_file(monkeypatch, tmp_path):
|
| 19 |
+
writes = []
|
| 20 |
+
|
| 21 |
+
def fake_write(path, data, sample_rate):
|
| 22 |
+
writes.append((path, data, sample_rate))
|
| 23 |
+
|
| 24 |
+
monkeypatch.setattr(tts_module, "get_tts_model", lambda: FakeTTS())
|
| 25 |
+
monkeypatch.setattr(tts_module.tempfile, "gettempdir", lambda: str(tmp_path))
|
| 26 |
+
monkeypatch.setitem(sys.modules, "soundfile", SimpleNamespace(write=fake_write))
|
| 27 |
+
|
| 28 |
+
output_path = tts_module.generate_tts("Hello.", "English", "Ryan", "Speak clearly.")
|
| 29 |
+
|
| 30 |
+
assert output_path.startswith(str(tmp_path))
|
| 31 |
+
assert writes == [(output_path, [0.0, 0.1, 0.0], 24000)]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_generate_tts_rejects_empty_text():
|
| 35 |
+
with pytest.raises(ValueError):
|
| 36 |
+
tts_module.generate_tts("", "English", "Ryan", "Speak clearly.")
|
| 37 |
+
|