Add export subcommand and Unity PoC-1 scaffold (ONDEVICE §4)

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 <noreply@anthropic.com>
This commit is contained in:
Oskar Kapala 2026-06-09 16:11:53 +02:00
parent 691c8ea360
commit 921a85d30b
9 changed files with 999 additions and 2 deletions

View file

@ -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/SPEC.md`](docs/SPEC.md) — specyfikacja techniczna etapu 1 (cel buildu).
- [`docs/LEXICON.md`](docs/LEXICON.md) — zweryfikowany leksykon (formy gramatyczne). - [`docs/LEXICON.md`](docs/LEXICON.md) — zweryfikowany leksykon (formy gramatyczne).
- [`docs/ROADMAP.md`](docs/ROADMAP.md) — etapy i ograniczenia platformy docelowej. - [`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 ## Zasada naczelna
Poprawność fleksji jest nienegocjowalna. Wszystkie formy słów pochodzą wyłącznie 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. LLM zwraca wyłącznie identyfikatory elementów; gramatykę buduje zawsze leksykon.
Przy błędzie (timeout, zły JSON, nieznany identyfikator) — fallback na generator losowy. 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 ## Testy
```bash ```bash
@ -69,4 +101,4 @@ pytest
``` ```
## Status ## Status
Etap 1 — zrealizowany zgodnie z `docs/SPEC.md`. Etap 1 zrealizowany wg `docs/SPEC.md`. PoC-1 (Quest 3): skrypty C# w `ondevice/`.

117
docs/ONDEVICE.md Normal file
View file

@ -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 13 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.

View file

@ -1,12 +1,22 @@
import argparse import argparse
import random import random
import sys
import time import time
from .export import run_export
from .generator import random_command, render from .generator import random_command, render
from .llm import llm_command from .llm import llm_command
def main() -> None: 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( parser = argparse.ArgumentParser(
description="ipin-vr: generator poleceń do terapii afazji" description="ipin-vr: generator poleceń do terapii afazji"
) )
@ -29,7 +39,7 @@ def main() -> None:
help="nazwa modelu") help="nazwa modelu")
parser.add_argument("--seed", type=int, metavar="N", parser.add_argument("--seed", type=int, metavar="N",
help="deterministyczny generator losowy") help="deterministyczny generator losowy")
args = parser.parse_args() args = parser.parse_args(sys.argv[1:])
if args.seed is not None: if args.seed is not None:
random.seed(args.seed) random.seed(args.seed)

107
ipin_vr/export.py Normal file
View file

@ -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)

193
ondevice/README.md Normal file
View file

@ -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.

View file

@ -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<CommandEntry> commands;
}
public class CommandBank : MonoBehaviour
{
[SerializeField] private string jsonFileName = "commands.json";
private readonly Dictionary<int, List<string>> _byLevel = new();
private bool _loaded;
public bool IsLoaded => _loaded;
/// <summary>Wczytaj bank asynchronicznie. onDone wywoływane po załadowaniu.</summary>
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<BankData>(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<string>();
list.Add(entry.text);
}
int total = data.commands.Count;
Debug.Log($"[CommandBank] Wczytano {total} poleceń (v{data.meta?.version})");
_loaded = true;
onDone?.Invoke();
}
/// <summary>Zwraca losowe polecenie dla danego poziomu, lub null jeśli brak.</summary>
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;
}
}

View file

@ -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 ---
/// <summary>Trigger "następne polecenie" — z przycisku kontrolera lub UI.</summary>
public void OnNextTrigger()
{
if (!_busy)
ShowNext();
}
/// <summary>Zmień poziom (13). Wywoływane przez przyciski poziomu w UI.</summary>
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);
}
}
}

View file

@ -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<AudioSource>();
}
/// <summary>Kopiuje pliki z StreamingAssets i inicjuje silnik TTS.</summary>
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();
}
/// <summary>Syntezuje text i odtwarza. Wywołuje onDone po zakończeniu odtwarzania.</summary>
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;
}
}
}

127
tests/test_export.py Normal file
View file

@ -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