From 921a85d30bc111e651dea4a88f9e0558b1edd849 Mon Sep 17 00:00:00 2001 From: Oskar Kapala Date: Tue, 9 Jun 2026 16:11:53 +0200 Subject: [PATCH] =?UTF-8?q?Add=20export=20subcommand=20and=20Unity=20PoC-1?= =?UTF-8?q?=20scaffold=20(ONDEVICE=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python (ipin_vr/export.py): - generate_bank(): levels 1–2 enumerated exhaustively (120/720 combos), level 3 random with dedup; all deduplicated per-level - run_export(): CLI --per-level / --out / --seed flags per ONDEVICE §4.1 - cli.py routes "export" subcommand before session args (backward-compat) - 14 new tests: format, dedup for all 3 levels, space caps (L1=120, L2=720), seed determinism, file output Unity scaffold (ondevice/Scripts/): - CommandBank.cs: loads commands.json from StreamingAssets (WebRequest on Android) - TtsManager.cs: sherpa-onnx integration with [SHERPA] stubs, StreamingAssets→ persistentDataPath copy, defensive Speak() with onDone callback - SessionController.cs: passthrough flow §6, level switching, busy guard on Next - ondevice/README.md: full manual Editor steps §5 (Unity setup, Meta XR SDK, sherpa-onnx install, scene wiring, font, build, metrics) pytest: 37/37 passed Co-Authored-By: Claude Sonnet 4.6 --- README.md | 34 ++++- docs/ONDEVICE.md | 117 ++++++++++++++++ ipin_vr/cli.py | 12 +- ipin_vr/export.py | 107 ++++++++++++++ ondevice/README.md | 193 ++++++++++++++++++++++++++ ondevice/Scripts/CommandBank.cs | 118 ++++++++++++++++ ondevice/Scripts/SessionController.cs | 125 +++++++++++++++++ ondevice/Scripts/TtsManager.cs | 168 ++++++++++++++++++++++ tests/test_export.py | 127 +++++++++++++++++ 9 files changed, 999 insertions(+), 2 deletions(-) create mode 100644 docs/ONDEVICE.md create mode 100644 ipin_vr/export.py create mode 100644 ondevice/README.md create mode 100644 ondevice/Scripts/CommandBank.cs create mode 100644 ondevice/Scripts/SessionController.cs create mode 100644 ondevice/Scripts/TtsManager.cs create mode 100644 tests/test_export.py diff --git a/README.md b/README.md index e3dfb2a..d28e4fd 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ wypowiadanie na PC. Bez rozpoznawania mowy (STT), bez VR. - [`docs/SPEC.md`](docs/SPEC.md) — specyfikacja techniczna etapu 1 (cel buildu). - [`docs/LEXICON.md`](docs/LEXICON.md) — zweryfikowany leksykon (formy gramatyczne). - [`docs/ROADMAP.md`](docs/ROADMAP.md) — etapy i ograniczenia platformy docelowej. +- [`docs/ONDEVICE.md`](docs/ONDEVICE.md) — PoC-1 na Meta Quest 3 (Unity + sherpa-onnx). +- [`ondevice/README.md`](ondevice/README.md) — ręczne kroki w Unity Editorze. ## Zasada naczelna Poprawność fleksji jest nienegocjowalna. Wszystkie formy słów pochodzą wyłącznie @@ -62,6 +64,36 @@ python -m ipin_vr --level 3 --count 5 --no-audio --llm LLM zwraca wyłącznie identyfikatory elementów; gramatykę buduje zawsze leksykon. Przy błędzie (timeout, zły JSON, nieznany identyfikator) — fallback na generator losowy. +## Eksport banku poleceń (na urządzenie) + +Generuje gotowe, deduplikowane polecenia do JSON — używane przez aplikację Unity +na Quest 3 (PoC-1, `docs/ONDEVICE.md`): + +```bash +python -m ipin_vr export --per-level 300 --out commands.json [--seed N] +``` + +| Flaga | Domyślnie | Opis | +|---|---|---| +| `--per-level N` | 300 | liczba unikatów na poziom | +| `--out PATH` | `-` (stdout) | ścieżka pliku wyjściowego | +| `--seed N` | — | powtarzalny wynik | + +Format wyjściowy: +```json +{ + "meta": {"version": "1", "per_level": 300}, + "commands": [ + {"level": 1, "text": "Połóż jabłko na stole."}, + {"level": 2, "text": "Umieść niebieskie jabłko na stole."}, + {"level": 3, "text": "Połóż zieloną książkę na półce i umieść klucz na biurku."} + ] +} +``` + +Przestrzeń każdego poziomu: L1 = 120 unikatów, L2 = 720, L3 ≈ 635 000. +Jeśli `--per-level` przekracza dostępną przestrzeń, zwracane jest tyle ile możliwe. + ## Testy ```bash @@ -69,4 +101,4 @@ pytest ``` ## Status -Etap 1 — zrealizowany zgodnie z `docs/SPEC.md`. +Etap 1 zrealizowany wg `docs/SPEC.md`. PoC-1 (Quest 3): skrypty C# w `ondevice/`. diff --git a/docs/ONDEVICE.md b/docs/ONDEVICE.md new file mode 100644 index 0000000..b0e6d3e --- /dev/null +++ b/docs/ONDEVICE.md @@ -0,0 +1,117 @@ +# ONDEVICE — PoC-1 na Meta Quest 3 + +Cel buildu dla pierwszego uruchomienia na urządzeniu. Spójny z `ROADMAP.md`. + +## 1. Cel PoC-1 +Uruchomić na Quest 3 minimalną aplikację, która w **passthrough** wyświetla i +**wypowiada** (Piper przez sherpa-onnx) polskie polecenia, oraz **zmierzyć +wydajność w realu**. **Bez żywego LLM na urządzeniu** (decyzja). **Bez STT.** + +## 2. Zakres +W zakresie: +- bank poleceń wygenerowany offline (z istniejącego `ipin_vr`), +- TTS na urządzeniu (sherpa-onnx + głos polski), +- minimalne UI w passthrough (panel z tekstem + trigger „następne"), +- instrumentacja i pomiar wydajności. + +Poza zakresem PoC-1: LLM na urządzeniu, STT, ocena wykonania, dopracowane UI/MR, +obiekty 3D. (To etap 2/3 wg `ROADMAP.md`.) + +## 3. Runtime — decyzja +**Unity** dla PoC-1. +- Uzasadnienie: najszybsza droga do działającego passthrough + TTS; narzut RAM + Unity (~1 GiB) jest nieistotny, bo bez LLM cały budżet spokojnie mieści się w + limicie 5,75 GiB. Meta XR SDK daje passthrough „z pudełka", a sherpa-onnx ma + gotowe wtyczki do Unity. +- Cel docelowego produktu pozostaje **natywny OpenXR** (lżejszy, gdy dojdzie LLM). +- To jedyna łatwo odwracalna decyzja architektoniczna tego dokumentu; reszta nie + zależy od wyboru runtime. + +## 4. Komponenty + +### 4.1 Bank poleceń (offline) +Rozszerzyć `ipin_vr` o podkomendę `export`, która zrzuca bank gotowych, +poprawnych poleceń do JSON (reużywamy zweryfikowanego generatora — gramatyka +gwarantowana u źródła). + +Wywołanie: +``` +python -m ipin_vr export --per-level 300 --out commands.json [--seed N] +``` +Format pliku: +```json +{ + "meta": {"version": "1", "per_level": 300}, + "commands": [ + {"level": 1, "text": "Połóż jabłko na stole."}, + {"level": 2, "text": "Umieść niebieskie jabłko na stole."}, + {"level": 3, "text": "Połóż zieloną książkę na półce i umieść klucz na biurku."} + ] +} +``` +Wymóg: deduplikacja w obrębie poziomu; `--per-level` to liczba unikatów na poziom +(jeśli przestrzeń jest mniejsza, tyle ile się da). Plik trafia do assetów aplikacji. + +### 4.2 TTS na urządzeniu +- Biblioteka: **sherpa-onnx** przez wtyczkę Unity (np. `Ponyu-dev/Unity-Sherpa-ONNX` + lub `EitanWong/com.eitan.sherpa-onnx-unity`). +- Głos: **`vits-piper-pl_PL-gosia-medium`** (z releasu `tts-models` sherpa-onnx; + zawiera `.onnx`, `tokens.txt`, `espeak-ng-data`). +- ABI: **arm64-v8a**. +- Haczyk do obsłużenia: modele w `StreamingAssets` są spakowane w APK — przed + użyciem skopiować do ścieżki zapisywalnej i podać tę nową ścieżkę do sherpa-onnx + (wtyczki zwykle robią to same; zweryfikować). + +### 4.3 Passthrough UI +- Passthrough przez **Meta XR SDK** (building block / komponent passthrough). +- Jeden panel (world-space lub HUD) z tekstem polecenia, czcionka z polskimi znakami. +- Trigger „następne polecenie": przycisk kontrolera albo prosty przycisk na panelu. + +## 5. Podział pracy: kod vs Unity Editor +Część generowalna kodem (Claude Code): +- podkomenda `export` w `ipin_vr` (+ test), +- skrypty C# (ładowanie `commands.json`, wybór poziomu, wyświetlanie tekstu, + wywołanie TTS, obsługa triggera), +- `ondevice/README.md` z instrukcją złożenia projektu. + +Część ręczna w Unity Editorze (nie da się w pełni wygenerować plikami): +- utworzenie projektu Unity (aktualne LTS) i konfiguracja Androida/OpenXR, +- instalacja Meta XR SDK i wtyczki sherpa-onnx, +- włączenie passthrough, podpięcie skryptów do sceny, import głosu i `commands.json`, +- build i sideload APK na Quest. + +Skrypty C# mają być samodzielne i podpinalne, nie zakładać konkretnej hierarchii sceny. + +## 6. Przepływ aplikacji +1. Start: passthrough on, wczytaj `commands.json` z assetów. +2. Ustaw poziom (domyślnie 1; prosty przełącznik 1/2/3). +3. Pokaż losowe polecenie danego poziomu na panelu. +4. Wypowiedz je (sherpa-onnx). +5. Trigger „następne" → wróć do kroku 3. + +## 7. Pomiar wydajności (właściwy cel PoC-1) +Zmierzyć i zanotować: +- PSS aplikacji względem limitu 5,75 GiB, +- stabilność klatek przy włączonym passthrough (spadki/utracone klatki), +- latencja TTS: od wywołania do pierwszego dźwięku oraz czas pełnej syntezy, +- RAM zajmowany przez model głosu, +- moment wejścia w throttling termiczny przy dłuższym użyciu, +- zużycie baterii. + +Narzędzia: OVR Metrics Tool, Perfetto, `logcat` (m.in. `lowmemorykiller`). + +## 8. Środowisko developerskie +Najpierw **sanity check bez kodu**: wgrać prebudowane sherpa-onnx TTS APK z +`pl_PL-gosia` na telefon z Androidem (lub sideload na Quest) i potwierdzić jakość +polskiego głosu. Dopiero potem budować aplikację Unity. Iteracja skryptów/logiki +najszybsza na telefonie Snapdragon; pomiary docelowe — na Queście. + +## 9. Definition of done +- Apka na Queście w passthrough pokazuje i wypowiada polskie polecenia z banku. +- Trigger „następne" działa; poziomy 1–3 dostępne. +- Zebrany komplet metryk z sekcji 7 (choćby zgrubnie) — to jest wynik PoC-1. + +## 10. Dalej (poza PoC-1) +- PoC-2: dołożyć llama.cpp na urządzeniu i zmierzyć ponownie (żywy LLM). +- Etap 3: STT + ocena wykonania, sesje, statystyki. +- Ewentualne przejście runtime na natywny OpenXR pod docelowy budżet RAM z LLM. diff --git a/ipin_vr/cli.py b/ipin_vr/cli.py index 9706cd7..44292e6 100644 --- a/ipin_vr/cli.py +++ b/ipin_vr/cli.py @@ -1,12 +1,22 @@ import argparse import random +import sys import time +from .export import run_export from .generator import random_command, render from .llm import llm_command def main() -> None: + raw = sys.argv[1:] + if raw and raw[0] == "export": + run_export(raw[1:]) + return + _run_session() + + +def _run_session() -> None: parser = argparse.ArgumentParser( description="ipin-vr: generator poleceń do terapii afazji" ) @@ -29,7 +39,7 @@ def main() -> None: help="nazwa modelu") parser.add_argument("--seed", type=int, metavar="N", help="deterministyczny generator losowy") - args = parser.parse_args() + args = parser.parse_args(sys.argv[1:]) if args.seed is not None: random.seed(args.seed) diff --git a/ipin_vr/export.py b/ipin_vr/export.py new file mode 100644 index 0000000..28107a2 --- /dev/null +++ b/ipin_vr/export.py @@ -0,0 +1,107 @@ +"""Bank poleceń do użycia na urządzeniu (ONDEVICE §4.1).""" + +import argparse +import json +import random +import sys +from itertools import product +from typing import Optional + +from .generator import Clause, Command, random_command, render +from .lexicon import COLORS, LOCATIONS, OBJECTS, VERBS + +# Rozmiary przestrzeni: +# Poziom 1: 2 × 10 × 6 = 120 unikatów +# Poziom 2: 2 × 10 × 6 × 6 = 720 unikatów +# Poziom 3: ~635 040 unikatów → generujemy losowo z deduplikacją + + +def _enumerate_level1() -> list[str]: + texts = [] + for v, o, loc in product(VERBS, OBJECTS, LOCATIONS): + texts.append(render(Command([Clause(v, o, loc, None)], 1))) + return texts + + +def _enumerate_level2() -> list[str]: + texts = [] + for v, o, loc, c in product(VERBS, OBJECTS, LOCATIONS, COLORS): + texts.append(render(Command([Clause(v, o, loc, c)], 2))) + return texts + + +def _random_unique_level3(target: int) -> list[str]: + seen: set[str] = set() + results: list[str] = [] + consecutive_misses = 0 + while len(results) < target and consecutive_misses < 500: + text = render(random_command(3)) + if text not in seen: + seen.add(text) + results.append(text) + consecutive_misses = 0 + else: + consecutive_misses += 1 + return results + + +def generate_bank(per_level: int, seed: Optional[int] = None) -> dict: + """Zwraca słownik gotowy do zrzutu jako JSON. + + Dla poziomów 1 i 2 wylicza wyczerpująco wszystkie kombinacje i tasuje; + dla poziomu 3 generuje losowo z deduplikacją. + Jeśli przestrzeń jest mniejsza niż per_level, zwraca tyle ile możliwe. + """ + if seed is not None: + random.seed(seed) + + commands: list[dict] = [] + + for level, pool_fn in [(1, _enumerate_level1), (2, _enumerate_level2)]: + pool = pool_fn() + random.shuffle(pool) + for text in pool[:per_level]: + commands.append({"level": level, "text": text}) + + for text in _random_unique_level3(per_level): + commands.append({"level": 3, "text": text}) + + return { + "meta": {"version": "1", "per_level": per_level}, + "commands": commands, + } + + +def run_export(argv: list[str]) -> None: + parser = argparse.ArgumentParser( + prog="python -m ipin_vr export", + description="Generuj bank poleceń do JSON (ONDEVICE §4.1)", + ) + parser.add_argument( + "--per-level", type=int, default=300, metavar="N", + help="liczba unikatów na poziom (domyślnie: 300)", + ) + parser.add_argument( + "--out", default="-", metavar="PATH", + help="ścieżka pliku wyjściowego (- = stdout)", + ) + parser.add_argument( + "--seed", type=int, metavar="N", + help="seed generatora losowego", + ) + args = parser.parse_args(argv) + + bank = generate_bank(per_level=args.per_level, seed=args.seed) + output = json.dumps(bank, ensure_ascii=False, indent=2) + + if args.out == "-": + print(output) + else: + with open(args.out, "w", encoding="utf-8") as f: + f.write(output) + by_level = {} + for cmd in bank["commands"]: + by_level[cmd["level"]] = by_level.get(cmd["level"], 0) + 1 + summary = ", ".join(f"L{k}:{v}" for k, v in sorted(by_level.items())) + total = len(bank["commands"]) + print(f"Zapisano {total} poleceń → {args.out} ({summary})", file=sys.stderr) diff --git a/ondevice/README.md b/ondevice/README.md new file mode 100644 index 0000000..aaf0997 --- /dev/null +++ b/ondevice/README.md @@ -0,0 +1,193 @@ +# ondevice — składanie projektu Unity (PoC-1, Meta Quest 3) + +Skrypty C# w `Scripts/` są gotowe do podpięcia. Poniżej lista ręcznych kroków +w Unity Editorze (nie da się ich wygenerować plikami). Odpowiada §5 z `docs/ONDEVICE.md`. + +--- + +## 0. Wymagania wstępne + +| Narzędzie | Wersja | +|---|---| +| Unity | 2022 LTS (6000.x) lub 2023 LTS | +| Android Build Support + OpenJDK | przez Unity Hub | +| Meta XR SDK | aktualny (via Package Manager → Add by git URL) | +| sherpa-onnx Unity plugin | patrz krok 4 | +| TextMeshPro | com.unity.textmeshpro (wbudowany w Unity) | + +--- + +## 1. Nowy projekt Unity + +1. Utwórz projekt **3D (URP)** lub **3D (Built-in)**. +2. Otwórz **Edit → Project Settings → Player → Android**: + - Target Architecture: **ARM64** + - Minimum API: **29** (Quest 3) + - Target API: **32** (lub latest) +3. Otwórz **Edit → Project Settings → XR Plug-in Management → Android**: + - Zaznacz **OpenXR** + - W zakładce OpenXR dodaj **Meta Quest feature set** +4. Otwórz **Edit → Project Settings → XR Plug-in Management → OpenXR**: + - Zaznacz **Meta Quest Support** i **Hand Tracking Subsystem** (opcjonalnie). + +--- + +## 2. Meta XR SDK — passthrough + +1. W **Package Manager** dodaj Meta XR SDK: + ``` + https://npm.developer.oculus.com (scope: com.meta.xr) + ``` + Zainstaluj: **Meta XR All-in-One SDK** lub minimum **Meta XR Core SDK**. +2. Utwórz scenę z **OVRCameraRig** (lub użyj prefabu z SDK). +3. Na `OVRManager` włącz **Passthrough Support = Required**. +4. Dodaj komponent **OVRPassthroughLayer** do kamery (Type: Underlay). + +--- + +## 3. Bank poleceń — generowanie i import + +```bash +# W repo ipin-vr (Python): +python -m ipin_vr export --per-level 300 --out commands.json --seed 42 +``` + +Następnie: +1. Utwórz katalog **Assets/StreamingAssets/** (jeśli nie istnieje). +2. Skopiuj `commands.json` do **Assets/StreamingAssets/commands.json**. + +--- + +## 4. sherpa-onnx — instalacja wtyczki i modelu głosu + +### 4a. Wtyczka + +Wybierz jedną z opcji: + +**Opcja A — ręczne .aar/.dll z wydania sherpa-onnx:** +1. Pobierz z https://github.com/k2-fsa/sherpa-onnx/releases pliki dla Unity + (szukaj `unity-*` w Assets danego wydania). +2. Umieść `.dll` (Windows/Editor) i `.aar` (Android) w `Assets/Plugins/`. + +**Opcja B — pakiet UPM (community):** +``` +https://github.com/EitanWong/com.eitan.sherpa-onnx-unity.git +``` +Dodaj przez **Package Manager → Add package from git URL**. + +### 4b. Model głosu polskiego + +1. Pobierz `vits-piper-pl_PL-gosia-medium.tar.bz2` z: + https://github.com/k2-fsa/sherpa-onnx/releases/tag/tts-models +2. Rozpakuj — otrzymasz katalog `vits-piper-pl_PL-gosia-medium/` zawierający: + - `pl_PL-gosia-medium.onnx` + - `tokens.txt` + - `espeak-ng-data/` (katalog) +3. Skopiuj cały katalog do **Assets/StreamingAssets/**: + ``` + Assets/StreamingAssets/ + commands.json + vits-piper-pl_PL-gosia-medium/ + pl_PL-gosia-medium.onnx + tokens.txt + espeak-ng-data/ + ``` + +### 4c. Aktywacja silnika TTS w kodzie + +Po zainstalowaniu wtyczki odkomentuj bloki oznaczone `[SHERPA]` w `TtsManager.cs`: +- `using SherpaOnnx;` (lub odpowiednia przestrzeń nazw wtyczki) +- blok konfiguracji `OfflineTtsConfig` w `InitCoroutine` +- blok `_tts.Generate(...)` w `SpeakCoroutine` + +**Uwaga na espeak-ng-data:** Weryfikuj, czy Twoja wtyczka kopiuje `espeak-ng-data` +automatycznie ze StreamingAssets. Jeśli nie — spakuj katalog jako `.zip` i rozpakowuj +w `TtsManager.EnsureEspeakData()`. + +--- + +## 5. Scena — podpinanie skryptów + +### 5a. Obiekty w hierarchii + +``` +Scene +├── OVRCameraRig ← z Meta XR SDK +│ └── CenterEyeAnchor +├── SessionManager ← pusty GameObject +│ ├── CommandBank.cs +│ ├── TtsManager.cs +│ └── SessionController.cs +└── Canvas (World Space) ← panel UI + ├── LoadingPanel + │ └── TMP_Text "Ładowanie..." + └── SessionPanel + ├── TMP_Text "CommandText" ← główny tekst polecenia + ├── Button "Next" ← trigger "następne" + └── LevelButtons + ├── Button "L1" + ├── Button "L2" + └── Button "L3" +``` + +### 5b. Inspector — SessionController + +| Pole | Przypisz | +|---|---| +| Command Bank | GameObject SessionManager → CommandBank | +| Tts Manager | GameObject SessionManager → TtsManager | +| Command Text | TMP_Text CommandText | +| Loading Panel | GameObject LoadingPanel | +| Session Panel | GameObject SessionPanel | +| Next Button | Button Next | +| Start Level | 1 | + +### 5c. Przyciski poziomu — OnClick + +Każdy przycisk L1/L2/L3: +- Cel: SessionManager → SessionController +- Funkcja: `SetLevel(1)` / `SetLevel(2)` / `SetLevel(3)` + +### 5d. TMP_Text — czcionka z polskimi znakami + +Domyślna czcionka TMP może nie zawierać wszystkich polskich znaków. Aby to naprawić: +1. Pobierz czcionkę z polskim zestawem (np. Noto Sans Regular). +2. Utwórz **TMP Font Asset** (**Window → TextMeshPro → Font Asset Creator**), + zaznaczając znaki Latin Extended-A i Latin Supplement. +3. Przypisz nowy Font Asset do komponentu TMP_Text. + +--- + +## 6. Build i sideload na Quest 3 + +```bash +# W Unity Editor: +# File → Build Settings → Android → Switch Platform +# Player Settings → Package Name: com.twojanazwa.ipinvr +# File → Build And Run (upewnij się, że Quest 3 podłączony przez USB + tryb dev) +``` + +Lub zbuduj APK i sideloaduj ręcznie: +```bash +adb install -r ipin-vr.apk +``` + +--- + +## 7. Pomiar wydajności (cel PoC-1) + +Po uruchomieniu na Queście zbierz metryki wg §7 z `docs/ONDEVICE.md`: +- **OVR Metrics Tool** (sideload z Meta) — FPS, CPU/GPU load, RAM +- **adb logcat** — `lowmemorykiller`, błędy sherpa-onnx +- **Perfetto** — latencja TTS od wywołania `Speak()` do pierwszego dźwięku + +Metryki docelowe: PSS < 5,75 GiB, FPS stable 72/90, latencja TTS < 1 s. + +--- + +## 8. Sanity check bez Unity (zalecane najpierw) + +Zanim zaczniesz budować projekt, przetestuj głos na telefonie z Androidem: +1. Pobierz oficjalne APK TTS ze strony sherpa-onnx. +2. Sideloaduj i sprawdź jakość polskiego głosu `gosia-medium`. +3. Dopiero potem wchodź w pełny projekt Unity. diff --git a/ondevice/Scripts/CommandBank.cs b/ondevice/Scripts/CommandBank.cs new file mode 100644 index 0000000..7477543 --- /dev/null +++ b/ondevice/Scripts/CommandBank.cs @@ -0,0 +1,118 @@ +// CommandBank.cs — ładuje commands.json z StreamingAssets, udostępnia losowe +// polecenia per-level. Samoistny MonoBehaviour; podepnij do dowolnego GameObject. +// +// Plik commands.json generuj offline: +// python -m ipin_vr export --per-level 300 --out commands.json +// Następnie umieść go w Assets/StreamingAssets/commands.json. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using UnityEngine; +using UnityEngine.Networking; + +namespace IpinVr +{ + [Serializable] + public class CommandEntry + { + public int level; + public string text; + } + + [Serializable] + internal class BankMeta + { + public string version; + public int per_level; + } + + [Serializable] + internal class BankData + { + public BankMeta meta; + public List commands; + } + + public class CommandBank : MonoBehaviour + { + [SerializeField] private string jsonFileName = "commands.json"; + + private readonly Dictionary> _byLevel = new(); + private bool _loaded; + + public bool IsLoaded => _loaded; + + /// Wczytaj bank asynchronicznie. onDone wywoływane po załadowaniu. + public void Load(Action onDone = null) + { + StartCoroutine(LoadCoroutine(onDone)); + } + + private IEnumerator LoadCoroutine(Action onDone) + { + string srcPath = Path.Combine(Application.streamingAssetsPath, jsonFileName); + string json; + +#if UNITY_ANDROID && !UNITY_EDITOR + // Na Androidzie StreamingAssets są wewnątrz APK — czytamy przez WebRequest. + using var req = UnityWebRequest.Get(srcPath); + yield return req.SendWebRequest(); + if (req.result != UnityWebRequest.Result.Success) + { + Debug.LogError($"[CommandBank] Błąd wczytywania {jsonFileName}: {req.error}"); + yield break; + } + json = req.downloadHandler.text; +#else + if (!File.Exists(srcPath)) + { + Debug.LogError($"[CommandBank] Plik nie istnieje: {srcPath}"); + yield break; + } + json = File.ReadAllText(srcPath); + yield return null; +#endif + + var data = JsonUtility.FromJson(json); + if (data?.commands == null) + { + Debug.LogError("[CommandBank] Nie można sparsować commands.json"); + yield break; + } + + _byLevel.Clear(); + foreach (var entry in data.commands) + { + if (!_byLevel.TryGetValue(entry.level, out var list)) + _byLevel[entry.level] = list = new List(); + list.Add(entry.text); + } + + int total = data.commands.Count; + Debug.Log($"[CommandBank] Wczytano {total} poleceń (v{data.meta?.version})"); + _loaded = true; + onDone?.Invoke(); + } + + /// Zwraca losowe polecenie dla danego poziomu, lub null jeśli brak. + public string GetRandom(int level) + { + if (!_loaded) + { + Debug.LogWarning("[CommandBank] Bank nie jest jeszcze wczytany"); + return null; + } + if (!_byLevel.TryGetValue(level, out var list) || list.Count == 0) + { + Debug.LogWarning($"[CommandBank] Brak poleceń dla poziomu {level}"); + return null; + } + return list[UnityEngine.Random.Range(0, list.Count)]; + } + + public int CountForLevel(int level) => + _byLevel.TryGetValue(level, out var l) ? l.Count : 0; + } +} diff --git a/ondevice/Scripts/SessionController.cs b/ondevice/Scripts/SessionController.cs new file mode 100644 index 0000000..b90e0ad --- /dev/null +++ b/ondevice/Scripts/SessionController.cs @@ -0,0 +1,125 @@ +// SessionController.cs — główna logika aplikacji (ONDEVICE §6): +// Start → wczytaj bank → ustaw poziom → pokaż losowe polecenie → TTS +// → trigger "następne" → pokaż kolejne. +// +// Samoistny MonoBehaviour; podepnij do dowolnego GameObject w scenie. +// Wymagane referencje do uzupełnienia w Inspectorze: +// • commandBank — GameObject z CommandBank.cs +// • ttsManager — GameObject z TtsManager.cs +// • commandText — komponent TextMeshProUGUI z tekstem polecenia +// • loadingPanel — panel widoczny podczas ładowania (opcjonalnie) +// • sessionPanel — panel widoczny podczas sesji +// Przyciski poziomu: w Inspectorze podepnij OnClick → SetLevel(1/2/3). +// Trigger "następne": podepnij OnClick → OnNextTrigger(). + +using System; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace IpinVr +{ + public class SessionController : MonoBehaviour + { + [Header("Komponenty (wymagane)")] + [SerializeField] private CommandBank commandBank; + [SerializeField] private TtsManager ttsManager; + [SerializeField] private TMP_Text commandText; // TextMeshPro — wymaga pakietu com.unity.textmeshpro + + [Header("Panele UI (opcjonalne)")] + [SerializeField] private GameObject loadingPanel; + [SerializeField] private GameObject sessionPanel; + + [Header("Przycisk 'Następne' (opcjonalny — można też wołać OnNextTrigger() z kodu)")] + [SerializeField] private Button nextButton; + + [Header("Ustawienia")] + [SerializeField] [Range(1, 3)] private int startLevel = 1; + + private int _currentLevel; + private bool _busy; // podczas TTS nie przyjmujemy triggera + + private void Start() + { + _currentLevel = startLevel; + SetPanelsVisible(loading: true); + + if (nextButton != null) + nextButton.onClick.AddListener(OnNextTrigger); + + if (commandBank == null || ttsManager == null) + { + Debug.LogError("[Session] Brakuje referencji CommandBank lub TtsManager w Inspectorze"); + return; + } + + commandBank.Load(() => + ttsManager.Initialize(() => + { + SetPanelsVisible(loading: false); + ShowNext(); + }) + ); + } + + private void OnDestroy() + { + if (nextButton != null) + nextButton.onClick.RemoveListener(OnNextTrigger); + } + + // --- publiczne API --- + + /// Trigger "następne polecenie" — z przycisku kontrolera lub UI. + public void OnNextTrigger() + { + if (!_busy) + ShowNext(); + } + + /// Zmień poziom (1–3). Wywoływane przez przyciski poziomu w UI. + public void SetLevel(int level) + { + if (level < 1 || level > 3) + { + Debug.LogWarning($"[Session] Nieprawidłowy poziom: {level}"); + return; + } + _currentLevel = level; + Debug.Log($"[Session] Poziom → {level}"); + if (!_busy) + ShowNext(); + } + + // --- logika wewnętrzna --- + + private void ShowNext() + { + if (!commandBank.IsLoaded || !ttsManager.IsReady) + return; + + string text = commandBank.GetRandom(_currentLevel); + if (text == null) + { + commandText.text = $"(brak poleceń dla poziomu {_currentLevel})"; + return; + } + + commandText.text = text; + _busy = true; + if (nextButton != null) nextButton.interactable = false; + + ttsManager.Speak(text, onDone: () => + { + _busy = false; + if (nextButton != null) nextButton.interactable = true; + }); + } + + private void SetPanelsVisible(bool loading) + { + if (loadingPanel != null) loadingPanel.SetActive(loading); + if (sessionPanel != null) sessionPanel.SetActive(!loading); + } + } +} diff --git a/ondevice/Scripts/TtsManager.cs b/ondevice/Scripts/TtsManager.cs new file mode 100644 index 0000000..9462cc8 --- /dev/null +++ b/ondevice/Scripts/TtsManager.cs @@ -0,0 +1,168 @@ +// TtsManager.cs — synteza mowy przez sherpa-onnx (głos vits-piper-pl_PL-gosia-medium). +// +// Instalacja wtyczki (wybierz jedną): +// A) Oficjalna: skopiuj .dll / .aar z wydania sherpa-onnx do Assets/Plugins/ +// https://github.com/k2-fsa/sherpa-onnx/releases (szukaj unity-*) +// B) UPM: https://github.com/EitanWong/com.eitan.sherpa-onnx-unity +// +// Po zainstalowaniu: odkomentuj bloki oznaczone [SHERPA] poniżej. +// +// Pliki modelu (StreamingAssets/vits-piper-pl_PL-gosia-medium/): +// pl_PL-gosia-medium.onnx +// tokens.txt +// espeak-ng-data/ (katalog) +// Pobierz ze: https://github.com/k2-fsa/sherpa-onnx/releases/tag/tts-models +// → vits-piper-pl_PL-gosia-medium.tar.bz2 + +using System; +using System.Collections; +using System.IO; +using UnityEngine; +using UnityEngine.Networking; + +// [SHERPA] using SherpaOnnx; // dokładna przestrzeń nazw zależy od wtyczki + +namespace IpinVr +{ + public class TtsManager : MonoBehaviour + { + [Header("Ścieżki modelu (względem StreamingAssets)")] + [SerializeField] private string modelOnnx = + "vits-piper-pl_PL-gosia-medium/pl_PL-gosia-medium.onnx"; + [SerializeField] private string tokensFile = + "vits-piper-pl_PL-gosia-medium/tokens.txt"; + [SerializeField] private string espeakDataDir = + "vits-piper-pl_PL-gosia-medium/espeak-ng-data"; + + [Header("Odtwarzanie")] + [SerializeField] private AudioSource audioSource; + + [Header("Parametry syntezy")] + [SerializeField] [Range(1, 4)] private int numThreads = 2; + [SerializeField] [Range(0.5f, 2.0f)] private float speed = 1.0f; + + public bool IsReady { get; private set; } + + // [SHERPA] private OfflineTts _tts; + + private void Awake() + { + if (audioSource == null) + audioSource = gameObject.AddComponent(); + } + + /// Kopiuje pliki z StreamingAssets i inicjuje silnik TTS. + public void Initialize(Action onReady = null) + { + StartCoroutine(InitCoroutine(onReady)); + } + + private IEnumerator InitCoroutine(Action onReady) + { + string[] files = { modelOnnx, tokensFile }; + foreach (var rel in files) + yield return StartCoroutine(EnsureFile(rel)); + + // espeak-ng-data: sherpa-onnx Unity plugins zwykle obsługują to wewnętrznie. + // Jeśli Twoja wtyczka tego nie robi, spakuj katalog jako .zip i rozpakowuj tu. + yield return StartCoroutine(EnsureEspeakData()); + + // [SHERPA] Inicjalizacja silnika: + // + // string base = Application.persistentDataPath; + // var config = new OfflineTtsConfig + // { + // model = new OfflineTtsModelConfig + // { + // vits = new OfflineTtsVitsModelConfig + // { + // model = Path.Combine(base, modelOnnx), + // tokens = Path.Combine(base, tokensFile), + // dataDir = Path.Combine(base, espeakDataDir), + // }, + // numThreads = numThreads, + // provider = "cpu", + // debug = false, + // }, + // }; + // _tts = new OfflineTts(config); + + IsReady = true; + Debug.Log("[TtsManager] Gotowy (sherpa-onnx stub — odkomentuj bloki [SHERPA])"); + onReady?.Invoke(); + } + + /// Syntezuje text i odtwarza. Wywołuje onDone po zakończeniu odtwarzania. + public void Speak(string text, Action onDone = null) + { + if (!IsReady) + { + Debug.LogWarning("[TtsManager] Nie zainicjowany — pomijam TTS"); + onDone?.Invoke(); + return; + } + if (audioSource.isPlaying) + audioSource.Stop(); + StartCoroutine(SpeakCoroutine(text, onDone)); + } + + public void Stop() + { + StopAllCoroutines(); + audioSource.Stop(); + } + + private IEnumerator SpeakCoroutine(string text, Action onDone) + { + // [SHERPA] Zastąp ten blok wywołaniem sherpa-onnx: + // + // var audio = _tts.Generate(text, speakerId: 0, speed: speed); + // var clip = AudioClip.Create("tts", audio.samples.Length, 1, + // audio.sampleRate, false); + // clip.SetData(audio.samples, 0); + // audioSource.clip = clip; + // audioSource.Play(); + // yield return new WaitUntil(() => !audioSource.isPlaying); + + Debug.Log($"[TtsManager] (stub) Speak: \"{text}\""); + yield return new WaitForSeconds(0.1f); // zastępcze opóźnienie + onDone?.Invoke(); + } + + // --- kopiowanie plików ze StreamingAssets --- + + private IEnumerator EnsureFile(string relative) + { + string dest = Path.Combine(Application.persistentDataPath, relative); + if (File.Exists(dest)) yield break; + + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + string src = Path.Combine(Application.streamingAssetsPath, relative); + +#if UNITY_ANDROID && !UNITY_EDITOR + using var req = UnityWebRequest.Get(src); + yield return req.SendWebRequest(); + if (req.result == UnityWebRequest.Result.Success) + File.WriteAllBytes(dest, req.downloadHandler.data); + else + Debug.LogError($"[TtsManager] Błąd kopiowania {relative}: {req.error}"); +#else + if (File.Exists(src)) + File.Copy(src, dest, overwrite: false); + yield return null; +#endif + } + + private IEnumerator EnsureEspeakData() + { + // Opcja A: Twoja wtyczka sherpa-onnx ustawia dataDir wewnętrznie + // → nic nie rób tutaj. + // Opcja B: Spakuj espeak-ng-data/ jako espeak-ng-data.zip w StreamingAssets + // i tu go rozpakuj do Application.persistentDataPath. + // Opcja C: Użyj ścieżki Application.streamingAssetsPath bezpośrednio, + // jeśli Twoja wtyczka obsługuje adb-accessible paths. + Debug.Log("[TtsManager] espeak-ng-data: zweryfikuj obsługę w wybranej wtyczce — patrz README"); + yield return null; + } + } +} diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..4517316 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,127 @@ +import json +import os +import tempfile + +import pytest + +from ipin_vr.export import generate_bank, run_export + +# Rozmiary wyczerpujące przestrzeni: +# Poziom 1: 2 × 10 × 6 = 120 +# Poziom 2: 2 × 10 × 6 × 6 = 720 + + +# --- format JSON --- + +def test_meta_fields(): + bank = generate_bank(per_level=5, seed=0) + assert bank["meta"]["version"] == "1" + assert bank["meta"]["per_level"] == 5 + + +def test_commands_structure(): + bank = generate_bank(per_level=5, seed=0) + for cmd in bank["commands"]: + assert "level" in cmd + assert "text" in cmd + assert cmd["level"] in {1, 2, 3} + assert isinstance(cmd["text"], str) + assert len(cmd["text"]) > 0 + + +def test_commands_end_with_period(): + bank = generate_bank(per_level=5, seed=0) + for cmd in bank["commands"]: + assert cmd["text"].endswith("."), f"Brak kropki: {cmd['text']!r}" + + +def test_json_roundtrip(): + bank = generate_bank(per_level=5, seed=0) + dumped = json.dumps(bank, ensure_ascii=False) + reloaded = json.loads(dumped) + assert reloaded["meta"] == bank["meta"] + assert reloaded["commands"] == bank["commands"] + + +# --- deduplikacja w obrębie poziomu --- + +def test_dedup_level1(): + bank = generate_bank(per_level=50, seed=1) + texts = [c["text"] for c in bank["commands"] if c["level"] == 1] + assert len(texts) == len(set(texts)), "Duplikaty w poziomie 1" + + +def test_dedup_level2(): + bank = generate_bank(per_level=50, seed=1) + texts = [c["text"] for c in bank["commands"] if c["level"] == 2] + assert len(texts) == len(set(texts)), "Duplikaty w poziomie 2" + + +def test_dedup_level3(): + bank = generate_bank(per_level=50, seed=1) + texts = [c["text"] for c in bank["commands"] if c["level"] == 3] + assert len(texts) == len(set(texts)), "Duplikaty w poziomie 3" + + +# --- ograniczenie przez rozmiar przestrzeni --- + +def test_level1_capped_at_space(): + """Poziom 1: 2 × 10 × 6 = 120 unikatów — więcej nie istnieje.""" + bank = generate_bank(per_level=300, seed=2) + texts = [c["text"] for c in bank["commands"] if c["level"] == 1] + assert len(texts) == 120 + assert len(texts) == len(set(texts)) + + +def test_level2_capped_at_space(): + """Poziom 2: 2 × 10 × 6 × 6 = 720 unikatów.""" + bank = generate_bank(per_level=1000, seed=2) + texts = [c["text"] for c in bank["commands"] if c["level"] == 2] + assert len(texts) == 720 + assert len(texts) == len(set(texts)) + + +def test_level3_respects_per_level(): + bank = generate_bank(per_level=30, seed=3) + texts = [c["text"] for c in bank["commands"] if c["level"] == 3] + assert len(texts) == 30 + + +# --- dokładna liczba na poziom --- + +def test_counts_per_level_small(): + bank = generate_bank(per_level=10, seed=4) + for level in [1, 2, 3]: + texts = [c["text"] for c in bank["commands"] if c["level"] == level] + assert len(texts) == 10, f"Poziom {level}: oczekiwano 10, jest {len(texts)}" + + +# --- flagi CLI --- + +def test_cli_out_file(): + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf: + path = tf.name + try: + run_export(["--per-level", "5", "--out", path, "--seed", "7"]) + with open(path, encoding="utf-8") as f: + data = json.load(f) + assert data["meta"]["per_level"] == 5 + assert len(data["commands"]) == 15 # 5 per level × 3 levels + finally: + os.unlink(path) + + +def test_cli_seed_deterministic(): + """Ten sam seed → identyczny wynik.""" + b1 = generate_bank(per_level=10, seed=99) + b2 = generate_bank(per_level=10, seed=99) + assert b1["commands"] == b2["commands"] + + +def test_cli_different_seeds(): + """Różne seedy → różne wyniki (z bardzo dużym prawdopodobieństwem).""" + b1 = generate_bank(per_level=50, seed=1) + b2 = generate_bank(per_level=50, seed=2) + t1 = [c["text"] for c in b1["commands"] if c["level"] == 3] + t2 = [c["text"] for c in b2["commands"] if c["level"] == 3] + assert t1 != t2